diff --git a/packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts b/packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts index 85941baa37..ad5115f1a9 100644 --- a/packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts +++ b/packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts @@ -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 () => { @@ -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)); diff --git a/packages/cogify/src/cogify/cli/cli.cog.ts b/packages/cogify/src/cogify/cli/cli.cog.ts index 6a80fe0b89..7bfd64a7c2 100644 --- a/packages/cogify/src/cogify/cli/cli.cog.ts +++ b/packages/cogify/src/cogify/cli/cli.cog.ts @@ -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'; @@ -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))); @@ -332,9 +331,26 @@ async function createCog(ctx: CogCreationContext): Promise { ); 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; } diff --git a/packages/cogify/src/cogify/cli/cli.cover.ts b/packages/cogify/src/cogify/cli/cli.cover.ts index 3cbd33c9af..b13b6d0d35 100644 --- a/packages/cogify/src/cogify/cli/cli.cover.ts +++ b/packages/cogify/src/cogify/cli/cli.cover.ts @@ -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]; @@ -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(); @@ -95,6 +100,7 @@ export const BasemapsCogifyCoverCommand = command({ metrics, cutline, preset: args.preset, + background: args.background, targetZoomOffset: args.baseZoomOffset, }; diff --git a/packages/cogify/src/cogify/gdal.command.ts b/packages/cogify/src/cogify/gdal.command.ts index 68653d4e2e..ae61833c02 100644 --- a/packages/cogify/src/cogify/gdal.command.ts +++ b/packages/cogify/src/cogify/gdal.command.ts @@ -1,3 +1,4 @@ +import { Rgba } from '@basemaps/config'; import { Epsg, EpsgCode, TileMatrixSets } from '@basemaps/geo'; import { urlToString } from '@basemaps/shared'; @@ -5,6 +6,8 @@ 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 { @@ -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), + }; +} diff --git a/packages/cogify/src/cogify/gdal.runner.ts b/packages/cogify/src/cogify/gdal.runner.ts index 7d59770b2d..74dfddd610 100644 --- a/packages/cogify/src/cogify/gdal.runner.ts +++ b/packages/cogify/src/cogify/gdal.runner.ts @@ -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[]; } diff --git a/packages/cogify/src/cogify/parsers.ts b/packages/cogify/src/cogify/parsers.ts index 5ef29be37b..de06d21c1a 100644 --- a/packages/cogify/src/cogify/parsers.ts +++ b/packages/cogify/src/cogify/parsers.ts @@ -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 = { + 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`. **/ @@ -19,7 +31,7 @@ export const Url: Type = { }; /** - * 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 diff --git a/packages/cogify/src/cogify/stac.ts b/packages/cogify/src/cogify/stac.ts index 1340f92155..d21f0f9cd7 100644 --- a/packages/cogify/src/cogify/stac.ts +++ b/packages/cogify/src/cogify/stac.ts @@ -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'; @@ -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; diff --git a/packages/cogify/src/tile.cover.ts b/packages/cogify/src/tile.cover.ts index e7cc125250..1115ad98a2 100644 --- a/packages/cogify/src/tile.cover.ts +++ b/packages/cogify/src/tile.cover.ts @@ -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'; @@ -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 */ @@ -180,6 +183,9 @@ export async function createTileCover(ctx: TileCoverContext): Promise