diff --git a/package-lock.json b/package-lock.json index ac82f74..dd346fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "contour-generator", - "version": "2.0.6", + "version": "2.0.7", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/src/generate-contour-tile-pyramid.ts b/src/generate-contour-tile-pyramid.ts index 3cdd7f8..ab0b53c 100644 --- a/src/generate-contour-tile-pyramid.ts +++ b/src/generate-contour-tile-pyramid.ts @@ -14,12 +14,17 @@ import { openPMtiles, pmtilesTester, } from "./pmtiles-adapter.js"; -// Import MBTiles adapter functions, tester, AND the metadata structure from openMBTiles import { openMBTiles, getMBTilesTile, mbtilesTester, } from "./mbtiles-adapter.js"; +import { + fetchSparseTile, + imageDataToBlob, + type SparseTileResult, + type ResamplingMethod, +} from "./sparse-tile-adapter.js"; import { getChildren } from "@mapbox/tilebelt"; import path from "path"; @@ -85,7 +90,33 @@ program "The image format for generated blank tiles ('png', 'webp', or 'jpeg'). This is used as a fallback if the source format cannot be determined.", "png", // Default format for blank tiles ) - // ADD THE VERBOSE OPTION HERE + .option( + "--useSparseMode", + "Enable sparse tile mode: upscale and crop lower zoom tiles instead of generating blank tiles when DEM tiles are missing.", + ) + .option( + "--sparseResamplingMethod ", + "The resampling method used for upscaling sparse tiles ('nearest', 'bilinear', or 'bicubic').", + (value) => { + if (value !== "nearest" && value !== "bilinear" && value !== "bicubic") { + throw new Error( + "Invalid value for --sparseResamplingMethod, must be 'nearest', 'bilinear', or 'bicubic'", + ); + } + return value; + }, + "bicubic", // default value + ) + .option( + "--sparseMaxZoomFallback ", + "Maximum number of zoom levels to fallback when searching for sparse tiles.", + "3" + ) + .option( + "--sparseTimeoutMs ", + "Timeout in milliseconds for sparse tile fetch attempts.", + "5000" + ) .option("-v, --verbose", "Enable verbose output.") .parse(process.argv); @@ -103,7 +134,11 @@ const { outputDir, blankTileSize, blankTileFormat, - verbose, // Capture the verbose option + useSparseMode, + sparseResamplingMethod, + sparseMaxZoomFallback, + sparseTimeoutMs, + verbose, } = options; const numX = Number(x); @@ -114,6 +149,8 @@ const numsourceMaxZoom = Number(sourceMaxZoom); const numIncrement = Number(increment); const numoutputMaxZoom = Number(outputMaxZoom); const numblankTileSize = Number(blankTileSize); +const numSparseMaxZoomFallback = Number(sparseMaxZoomFallback); +const numSparseTimeoutMs = Number(sparseTimeoutMs); const validBlankTileFormats = ["png", "webp", "jpeg"]; if (!validBlankTileFormats.includes(blankTileFormat)) { @@ -125,6 +162,16 @@ if (!validBlankTileFormats.includes(blankTileFormat)) { process.exit(1); } +const validSparseResamplingMethods = ["nearest", "bilinear", "bicubic"]; +if (!validSparseResamplingMethods.includes(sparseResamplingMethod)) { + console.error( + `Invalid value for --sparseResamplingMethod: ${sparseResamplingMethod}. Must be one of: ${validSparseResamplingMethods.join( + ", ", + )}`, + ); + process.exit(1); +} + // -------------------------------------------------- // mlcontour options/defaults // -------------------------------------------------- @@ -225,9 +272,58 @@ const pmtilesFetcher: TileFetcher = async ( ); if (!zxyTileData) { - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + pmtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; + const formatForSparse = sourceMimeType.split("/")[1] || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, formatForSparse); + + return { + data: blob, + mimeType: sourceMimeType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); + if (verbose) { + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); + } + } + + if (verbose) { + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); + } + } + const sourceMimeType = pmtilesMimeType || `image/${blankTileFormat}`; const formatForBlank = sourceMimeType.split("/")[1] || blankTileFormat; @@ -239,7 +335,7 @@ const pmtilesFetcher: TileFetcher = async ( formatForBlank as any, ); return { - data: new Blob([blankTileBuffer], { type: sourceMimeType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: sourceMimeType }), mimeType: sourceMimeType, expires: undefined, cacheControl: undefined, @@ -283,11 +379,61 @@ const mbtilesFetcher: TileFetcher = async ( ); if (!tileData || !tileData.data) { - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); - const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + // Tile not found - check if we should use sparse mode + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); + if (verbose) { + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); + } + } + if (verbose) { + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); + } + } + + // Fall back to blank tile generation + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; const blankTileBuffer = await createBlankTileImage( numblankTileSize, numblankTileSize, @@ -297,7 +443,7 @@ const mbtilesFetcher: TileFetcher = async ( ); const blobType = `image/${sourceFormat}`; return { - data: new Blob([blankTileBuffer], { type: blobType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, @@ -309,7 +455,7 @@ const mbtilesFetcher: TileFetcher = async ( blobType = tileData.contentType; } return { - data: new Blob([tileData.data], { type: blobType }), + data: new Blob([new Uint8Array(tileData.data)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, @@ -319,11 +465,61 @@ const mbtilesFetcher: TileFetcher = async ( error.message.includes("Tile does not exist") || error.message.includes("no such row") ) { - console.warn( - `DEM tile not found for ${url} (z:${zxy.z}, x:${zxy.x}, y:${zxy.y}). Generating blank tile.`, - ); - const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + // Same sparse fallback logic as above + if (useSparseMode) { + if (verbose) { + console.log( + `[Fetcher] DEM tile not found for ${url}, attempting sparse tile fetch`, + ); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numSparseTimeoutMs); + + try { + const sparseResult = await fetchSparseTile( + zxy.z, + zxy.x, + zxy.y, + numblankTileSize, + mbtilesFetcher, + demUrlPattern || "", + sparseResamplingMethod, + verbose, + numSparseMaxZoomFallback + ); + clearTimeout(timeout); + + if (sparseResult) { + if (verbose) { + console.log(`[Fetcher] Successfully created sparse tile for ${url}`); + } + + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; + const blob = await imageDataToBlob(sparseResult.imageData, sourceFormat); + const blobType = `image/${sourceFormat}`; + + return { + data: blob, + mimeType: blobType, + expires: undefined, + cacheControl: undefined, + }; + } + } catch (error: any) { + clearTimeout(timeout); + if (verbose) { + console.log(`[Fetcher] Sparse tile fetch timed out or failed: ${error.message}`); + } + } + + if (verbose) { + console.log(`[Fetcher] No parent tiles found for sparse mode, falling back to blank tile`); + } + } + + // Fall back to blank tile generation + const sourceFormat = mbtilesSource.metadata?.format || blankTileFormat; const blankTileBuffer = await createBlankTileImage( numblankTileSize, numblankTileSize, @@ -333,7 +529,7 @@ const mbtilesFetcher: TileFetcher = async ( ); const blobType = `image/${sourceFormat}`; return { - data: new Blob([blankTileBuffer], { type: blobType }), + data: new Blob([new Uint8Array(blankTileBuffer)], { type: blobType }), mimeType: blobType, expires: undefined, cacheControl: undefined, diff --git a/src/index.ts b/src/index.ts index ed845c4..6358585 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ type BaseOptions = { blankTileNoDataValue: number; blankTileSize: number; blankTileFormat: string; + useSparseMode: boolean; + sparseResamplingMethod: string; }; type PyramidOptions = BaseOptions & { @@ -149,6 +151,13 @@ async function processTile(options: PyramidOptions): Promise { options.blankTileFormat, ]; + // Add sparse mode options if enabled + if (options.useSparseMode) { + commandArgs.push("--useSparseMode"); + commandArgs.push("--sparseResamplingMethod"); + commandArgs.push(options.sparseResamplingMethod); + } + // Pass the verbose flag down to the child process ONLY if the orchestrator is verbose if (options.verbose) { commandArgs.push("--verbose"); // Pass the verbose flag to the child @@ -446,6 +455,20 @@ async function main(): Promise { "The image format for generated blank tiles ('png', 'webp', or 'jpeg').", defaultValue: "png", }, + { + type: "standard", + flags: "--useSparseMode", + description: + "Enable sparse tile mode: upscale and crop lower zoom tiles instead of generating blank tiles when DEM tiles are missing.", + defaultValue: false, + }, + { + type: "standard", + flags: "--sparseResamplingMethod ", + description: + "The resampling method used for upscaling sparse tiles ('nearest', 'bilinear', or 'bicubic').", + defaultValue: "bicubic", + }, { type: "standard", flags: "-v, --verbose", diff --git a/src/sparse-tile-adapter.ts b/src/sparse-tile-adapter.ts new file mode 100644 index 0000000..8209487 --- /dev/null +++ b/src/sparse-tile-adapter.ts @@ -0,0 +1,317 @@ +// sparse-tile-adapter.ts + +export interface SparseTileResult { + imageData: ImageData; + mimeType: string; +} + +export type ResamplingMethod = "nearest" | "bilinear" | "bicubic"; + +/** + * Calculate parent tile coordinates + */ +export function getParentTile( + z: number, + x: number, + y: number, +): { z: number; x: number; y: number } | null { + if (z === 0) return null; + return { + z: z - 1, + x: Math.floor(x / 2), + y: Math.floor(y / 2), + }; +} + +/** + * Calculate the offset and scaling needed to extract a child tile from a parent + */ +export function getChildTileRegion( + childZ: number, + childX: number, + childY: number, + parentZ: number, + tileSize: number, +): { offsetX: number; offsetY: number; scale: number } { + const zoomDiff = childZ - parentZ; + const tilesPerParent = Math.pow(2, zoomDiff); + const xOffset = childX % tilesPerParent; + const yOffset = childY % tilesPerParent; + const regionSize = tileSize / tilesPerParent; + + return { + offsetX: xOffset * regionSize, + offsetY: yOffset * regionSize, + scale: tilesPerParent, + }; +} + +/** + * Cubic interpolation kernel (Catmull-Rom / bicubic) + */ +function cubicKernel(x: number): number { + const absX = Math.abs(x); + if (absX <= 1) { + return 1.5 * absX * absX * absX - 2.5 * absX * absX + 1; + } else if (absX <= 2) { + return -0.5 * absX * absX * absX + 2.5 * absX * absX - 4 * absX + 2; + } + return 0; +} + +/** + * Bicubic interpolation for a single pixel + */ +function bicubicInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number, +): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const dx = x - xi; + const dy = y - yi; + + let value = 0; + + for (let m = -1; m <= 2; m++) { + for (let n = -1; n <= 2; n++) { + const sx = Math.max(0, Math.min(sourceWidth - 1, xi + n)); + const sy = Math.max(0, Math.min(sourceHeight - 1, yi + m)); + const idx = (sy * sourceWidth + sx) * 4 + channel; + const pixel = sourceData[idx]; + + value += pixel * cubicKernel(n - dx) * cubicKernel(m - dy); + } + } + + return Math.max(0, Math.min(255, value)); +} + +/** + * Bilinear interpolation for a single pixel + */ +function bilinearInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number, +): number { + const x1 = Math.floor(x); + const y1 = Math.floor(y); + const x2 = Math.min(x1 + 1, sourceWidth - 1); + const y2 = Math.min(y1 + 1, sourceHeight - 1); + + const dx = x - x1; + const dy = y - y1; + + const idx11 = (y1 * sourceWidth + x1) * 4 + channel; + const idx21 = (y1 * sourceWidth + x2) * 4 + channel; + const idx12 = (y2 * sourceWidth + x1) * 4 + channel; + const idx22 = (y2 * sourceWidth + x2) * 4 + channel; + + const v11 = sourceData[idx11]; + const v21 = sourceData[idx21]; + const v12 = sourceData[idx12]; + const v22 = sourceData[idx22]; + + const v1 = v11 * (1 - dx) + v21 * dx; + const v2 = v12 * (1 - dx) + v22 * dx; + + return v1 * (1 - dy) + v2 * dy; +} + +/** + * Nearest neighbor interpolation + */ +function nearestInterpolate( + sourceData: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + channel: number, +): number { + const xi = Math.round(x); + const yi = Math.round(y); + const sx = Math.max(0, Math.min(sourceWidth - 1, xi)); + const sy = Math.max(0, Math.min(sourceHeight - 1, yi)); + const idx = (sy * sourceWidth + sx) * 4 + channel; + return sourceData[idx]; +} + +/** + * Crop and scale a region from a parent tile to create a child tile with custom resampling + */ +export async function cropAndScaleTile( + parentImageData: ImageData, + offsetX: number, + offsetY: number, + scale: number, + targetSize: number, + resamplingMethod: ResamplingMethod = "bicubic", +): Promise { + const sourceSize = targetSize / scale; + const sourceData = parentImageData.data; + const sourceWidth = parentImageData.width; + const sourceHeight = parentImageData.height; + + const outputData = new Uint8ClampedArray(targetSize * targetSize * 4); + const scaleX = sourceSize / targetSize; + const scaleY = sourceSize / targetSize; + + const interpolate = + resamplingMethod === "bicubic" + ? bicubicInterpolate + : resamplingMethod === "bilinear" + ? bilinearInterpolate + : nearestInterpolate; + + for (let ty = 0; ty < targetSize; ty++) { + for (let tx = 0; tx < targetSize; tx++) { + const sx = offsetX + tx * scaleX; + const sy = offsetY + ty * scaleY; + const outIdx = (ty * targetSize + tx) * 4; + + for (let c = 0; c < 4; c++) { + outputData[outIdx + c] = interpolate( + sourceData, + sourceWidth, + sourceHeight, + sx, + sy, + c, + ); + } + } + } + + return new ImageData(outputData, targetSize, targetSize); +} + +/** + * Fetch a sparse tile by looking for parent tiles and upscaling + */ +export async function fetchSparseTile( + z: number, + x: number, + y: number, + tileSize: number, + fetcher: ( + url: string, + abortController: AbortController, + ) => Promise<{ data: Blob | undefined; mimeType?: string }>, + urlPattern: string, + resamplingMethod: ResamplingMethod = "bicubic", + verbose: boolean = false, + maxZoomFallback: number = 3, + abortController?: AbortController, +): Promise { + let currentZ = z; + let currentX = x; + let currentY = y; + let fallbackDepth = 0; + + while (currentZ >= 0 && fallbackDepth < maxZoomFallback) { + const parent = getParentTile(currentZ, currentX, currentY); + if (!parent) break; + + fallbackDepth++; + currentZ = parent.z; + currentX = parent.x; + currentY = parent.y; + + const parentUrl = urlPattern + .replace("{z}", currentZ.toString()) + .replace("{x}", currentX.toString()) + .replace("{y}", currentY.toString()); + + if (verbose) { + console.log( + `[SparseTile] Attempting to fetch parent tile z${currentZ}/${currentX}/${currentY}`, + ); + } + + try { + const result = await fetcher( + parentUrl, + abortController || new AbortController(), + ); + + if (result.data) { + if (verbose) { + console.log( + `[SparseTile] Found parent tile at z${currentZ}/${currentX}/${currentY}`, + ); + } + + const bitmap = await createImageBitmap(result.data); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + + ctx.drawImage(bitmap, 0, 0); + const parentImageData = ctx.getImageData( + 0, + 0, + bitmap.width, + bitmap.height, + ); + + const region = getChildTileRegion(z, x, y, currentZ, tileSize); + + const childImageData = await cropAndScaleTile( + parentImageData, + region.offsetX, + region.offsetY, + region.scale, + tileSize, + resamplingMethod, + ); + + return { + imageData: childImageData, + mimeType: result.mimeType || "image/png", + }; + } + } catch (error: any) { + if (verbose) { + console.log( + `[SparseTile] Fetch failed or timed out at z${currentZ}: ${error.message}`, + ); + } + } + } + + return null; +} + +/** + * Convert ImageData to Blob + */ +export async function imageDataToBlob( + imageData: ImageData, + format: string, +): Promise { + const canvas = new OffscreenCanvas(imageData.width, imageData.height); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + + ctx.putImageData(imageData, 0, 0); + + const mimeTypeMap: Record = { + png: "image/png", + webp: "image/webp", + jpeg: "image/jpeg", + jpg: "image/jpeg", + }; + + const mimeType = mimeTypeMap[format] || "image/png"; + return await canvas.convertToBlob({ type: mimeType }); +} +