UNPKG

@deepgis/dem-dynamic-terrain

Version:

使用 GDAL 制作地形瓦片,支持 mapbox 和 terrarium 两种编码输出格式

486 lines (485 loc) 16.9 kB
import { existsSync } from "node:fs"; import promises from "node:fs/promises"; import node_os from "node:os"; import node_path from "node:path"; import emittery from "emittery"; import gdal_async from "gdal-async"; import p_queue from "p-queue"; import tinypool from "tinypool"; import yocto_spinner from "yocto-spinner"; import { randomUUID } from "node:crypto"; var package_namespaceObject = JSON.parse('{"KR":"dem-dynamic-terrain"}'); function getBuildOverviewResampling(resampling) { switch(resampling){ case 1: return 'AVERAGE'; case 2: return 'BILINEAR'; case 3: return 'CUBIC'; case 4: return 'CUBICSPLINE'; case 5: return 'LANCZOS'; case 6: return 'MODE'; case 7: return 'NEAREST'; default: return 'CUBIC'; } } function getResampling(resampling) { switch(resampling){ case 1: return gdal_async.GRA_Average; case 2: return gdal_async.GRA_Bilinear; case 3: return gdal_async.GRA_Cubic; case 4: return gdal_async.GRA_CubicSpline; case 5: return gdal_async.GRA_Lanczos; case 6: return gdal_async.GRA_Mode; case 7: return gdal_async.GRA_NearestNeighbor; default: return gdal_async.GRA_Cubic; } } function reprojectImage(src_ds, reproject_path, t_epsg, resampling = 1) { let s_ds; s_ds = 'string' == typeof src_ds ? gdal_async.open(src_ds) : src_ds; const s_srs = s_ds.srs; const t_srs = gdal_async.SpatialReference.fromEPSGA(t_epsg); const { rasterSize, geoTransform } = gdal_async.suggestedWarpOutput({ src: s_ds, s_srs, t_srs }); const dataType = s_ds.bands.get(1).dataType; const t_driver = s_ds.driver; const t_ds = t_driver.create(reproject_path, rasterSize.x, rasterSize.y, s_ds.bands.count(), dataType); t_ds.srs = t_srs; t_ds.geoTransform = geoTransform; const gdal_resampling = getResampling(resampling); gdal_async.reprojectImage({ src: s_ds, dst: t_ds, s_srs, t_srs, resampling: gdal_resampling }); const bands = s_ds.bands.count(); for(let i = 0; i < bands; i++){ const s_band = s_ds.bands.get(i + 1); const t_band = t_ds.bands.get(i + 1); t_band.noDataValue = s_band.noDataValue; s_band.dataType; } t_ds.close(); if ('string' == typeof src_ds) s_ds.close(); } function createLogger(logger = true) { const log = (msg)=>{ if (logger) console.log(msg); }; return { log }; } const tileBoundMap = new Map(); tileBoundMap.set(3857, { xmin: -20037508.342789244, ymin: -20037508.342789244, xmax: 20037508.342789244, ymax: 20037508.342789244 }); tileBoundMap.set(900913, { xmin: -20037508.342789244, ymin: -20037508.342789244, xmax: 20037508.342789244, ymax: 20037508.342789244 }); tileBoundMap.set(4490, { xmin: -180, ymin: -90, xmax: 180, ymax: 90 }); tileBoundMap.set(4326, { xmin: -180, ymin: -90, xmax: 180, ymax: 90 }); function ST_TileEnvelope(z, x, y, offset = 0, bbox = tileBoundMap.get(3857)) { const tile_size = 256.0; const boundsWidth = bbox.xmax - bbox.xmin; const boundsHeight = bbox.ymax - bbox.ymin; if (boundsWidth <= 0 || boundsHeight <= 0) throw new Error('Geometric bounds are too small'); if (z < 0 || z >= 32) throw new Error(`Invalid tile zoom value, ${z}`); const worldTileSize = 0x01 << (z > 31 ? 31 : z); if (x < 0 || x >= worldTileSize) throw new Error(`Invalid tile x value, ${x}`); if (y < 0 || y >= worldTileSize) throw new Error(`Invalid tile y value, ${y}`); const tileGeoSizeX = boundsWidth / worldTileSize; const tileGeoSizeY = boundsHeight / worldTileSize; const tileGeoSize = Math.max(tileGeoSizeX, tileGeoSizeY); const x1 = bbox.xmin + tileGeoSize * x - tileGeoSize / tile_size * offset; const x2 = bbox.xmin + tileGeoSize * (x + 1) + tileGeoSize / tile_size * offset; const y1 = bbox.ymax - tileGeoSize * (y + 1) - tileGeoSize / tile_size * offset; const y2 = bbox.ymax - tileGeoSize * y + tileGeoSize / tile_size * offset; return [ x1, y1, x2, y2 ]; } function getTileByCoors(coord, zoom, bbox = tileBoundMap.get(3857)) { const left = bbox.xmin; const top = bbox.ymax; const _width = coord[0] - left; const _height = top - coord[1]; const worldTileSize = 0x01 << zoom; const boundsWidth = bbox.xmax - bbox.xmin; const boundsHeight = bbox.ymax - bbox.ymin; const tileGeoSize = Math.max(boundsWidth, boundsHeight) / worldTileSize; const row = Math.floor(_height / tileGeoSize); const column = Math.floor(_width / tileGeoSize); return { row, column }; } class TIF2Tiles { input; output; options; queue; pool; log = ()=>{}; sourceDs; projectDs; projectPath; encodePath; tileBoundTool; statistics = { tileCount: 0, completeCount: 0, levelInfo: {} }; id = randomUUID(); emitter = new emittery(); constructor(input, output, options){ this.input = input; this.output = output; this.options = options; this.pool = new tinypool({ filename: this.getWorkerPath(), runtime: 'child_process' }); this.queue = new p_queue({ concurrency: node_os.cpus().length, autoStart: false }); this.sourceDs = null; this.projectDs = null; this.projectPath = null; this.encodePath = null; this.tileBoundTool = { xmin: 0, ymin: 0, xmax: 0, ymax: 0 }; const { log } = createLogger(options.log); this.log = log; } getWorkerPath() { return new URL('./create-tile.js', import.meta.url).href; } async recycle() { const log = this.log; if (this.sourceDs) { try { this.sourceDs.close(); } catch (e) { log(e); } this.sourceDs = null; } if (this.projectDs) { try { this.projectDs.close(); } catch (e) { log(e); } this.projectDs = null; } if (this.projectPath && existsSync(this.projectPath)) { await promises.rm(this.projectPath, { recursive: true }); this.projectPath = null; } if (this.encodePath && existsSync(this.encodePath)) { await promises.rm(this.encodePath, { recursive: true }); this.encodePath = null; } const ovrPath = `${this.encodePath}.ovr`; if (existsSync(ovrPath)) await promises.rm(ovrPath, { recursive: true }); } async reproject(ds, epsg, resampling) { const projectDatasetPath = node_path.join(node_os.tmpdir(), package_namespaceObject.KR, `${this.id}.tif`); await promises.mkdir(node_path.dirname(projectDatasetPath), { recursive: true }); reprojectImage(ds, projectDatasetPath, epsg, resampling); return projectDatasetPath; } buildPyramid(ds, minZoom, resampling) { const res = ds.geoTransform[1]; const maxPixel = Math.min(ds.rasterSize.x, ds.rasterSize.y); let overviewNum = 1; while(maxPixel / 2 ** overviewNum > 256)overviewNum++; let res_zoom = (this.tileBoundTool.xmax - this.tileBoundTool.xmin) / 256; let originZ = 0; while(res_zoom / 2 > res){ res_zoom /= 2; originZ++; } const overviews = []; for(let zoom = originZ - 1; zoom >= originZ - 1 - overviewNum; zoom--){ if (zoom < minZoom) break; const factor = 2 ** (originZ - zoom); overviews.push(factor); } const buildOverviewResampling = getBuildOverviewResampling(resampling); ds.buildOverviews(buildOverviewResampling, overviews); return { maxOverViewsZ: originZ - 1, minOverViewsZ: originZ - overviews.length }; } async generateTile() { const log = this.log; const { input, output, options } = this; const type = options.type; const { minZoom, maxZoom, epsg, encoding, isClean, resampling } = options; const tileSize = 256; this.tileBoundTool = tileBoundMap.get(epsg); const isSavaMbtiles = '.mbtiles' === node_path.extname(output); let outputDir = output; if (true === isSavaMbtiles) outputDir = node_path.join(node_os.tmpdir(), this.id); let stepIndex = 0; if (isClean) { if (existsSync(output)) await promises.rm(output, { recursive: true }); await promises.mkdir(output, { recursive: true }); log(`- \u{6B65}\u{9AA4}${++stepIndex}: \u{6E05}\u{7A7A}\u{8F93}\u{51FA}\u{6587}\u{4EF6}\u{5939} - \u{5B8C}\u{6210}`); } this.sourceDs = gdal_async.open(input, 'r'); if (this.sourceDs.srs?.getAuthorityCode() !== epsg) { this.projectPath = await this.reproject(this.sourceDs, epsg, resampling); this.projectDs = gdal_async.open(this.projectPath, 'r+'); this.sourceDs.close(); log(`- \u{6B65}\u{9AA4}${++stepIndex}: \u{91CD}\u{6295}\u{5F71}\u{81F3} EPSG:${epsg} - \u{5B8C}\u{6210}`); } else this.projectDs = this.sourceDs; this.sourceDs = null; const overViewInfo = this.buildPyramid(this.projectDs, minZoom, resampling); log(`- \u{6B65}\u{9AA4}${++stepIndex}: \u{6784}\u{5EFA}\u{5F71}\u{50CF}\u{91D1}\u{5B57}\u{5854}\u{7D22}\u{5F15} - \u{5B8C}\u{6210}`); const projectDs = this.projectDs; const geoTransform = projectDs.geoTransform; const dsInfo = { width: projectDs.rasterSize.x, height: projectDs.rasterSize.y, resX: geoTransform[1], resY: geoTransform[5], startX: geoTransform[0], startY: geoTransform[3], endX: geoTransform[0] + projectDs.rasterSize.x * geoTransform[1], endY: geoTransform[3] + projectDs.rasterSize.y * geoTransform[5], path: projectDs.description }; let tileCount = 0; let miny; let maxy; if (dsInfo.startY < dsInfo.endY) { miny = dsInfo.startY; maxy = dsInfo.endY; } else { miny = dsInfo.endY; maxy = dsInfo.startY; } const startPoint = [ dsInfo.startX, maxy ]; const endPoint = [ dsInfo.endX, miny ]; for(let tz = minZoom; tz <= maxZoom; ++tz){ const minRC = getTileByCoors(startPoint, tz, this.tileBoundTool); const maxRC = getTileByCoors(endPoint, tz, this.tileBoundTool); this.statistics.tileCount += (maxRC.row - minRC.row + 1) * (maxRC.column - minRC.column + 1); this.statistics.levelInfo[tz] = { tminx: minRC.column, tminy: minRC.row, tmaxx: maxRC.column, tmaxy: maxRC.row }; } const buffer = 1; let outTileSize = tileSize; if ('mapbox' === encoding) outTileSize = tileSize + 2 * buffer; if ('dom' === type) outTileSize = tileSize; let spinner; spinner = options.log ? yocto_spinner({ text: `\u{6B65}\u{9AA4}${++stepIndex}: \u{5F00}\u{59CB}\u{51C6}\u{5907}\u{5207}\u{7247}\u{961F}\u{5217} - \u{5B8C}\u{6210}` }).start() : { text: '', error: ()=>{}, success: ()=>{} }; const queue = this.queue; const jobs = []; queue.on('completed', (result)=>{ jobs.push(result); const percent = Math.floor(jobs.length / tileCount * 100); spinner.text = `\u{5207}\u{7247}\u{8FDB}\u{5EA6}: ${percent}%`; this.emitter.emit('completed', { id: this.id, tileCount, createTileCount: jobs.length }); }); queue.on('idle', async ()=>{ if (jobs.length !== tileCount) spinner.error(`\u{6B65}\u{9AA4}${++stepIndex}: \u{5207}\u{7247} - \u{5931}\u{8D25}`); if (jobs.length === tileCount) spinner.success(`\u{6B65}\u{9AA4}${++stepIndex}: \u{5207}\u{7247} - \u{5B8C}\u{6210}`); this.resetStats(); await this.pool.destroy(); await this.recycle(); this.emitter.emit('idle', { id: this.id, tileCount, createTileCount: jobs.length }); }); for(let tz = minZoom; tz <= maxZoom; tz++){ const { tminx, tminy, tmaxx, tmaxy } = this.statistics.levelInfo[tz]; let overviewInfo; if (tz > overViewInfo.maxOverViewsZ) overviewInfo = dsInfo; else { const startZ = Math.max(tz, overViewInfo.minOverViewsZ); const factorZoom = overViewInfo.maxOverViewsZ - startZ; const factor = 2 ** (factorZoom + 1); overviewInfo = { index: factorZoom, startX: dsInfo.startX, startY: dsInfo.startY, width: Math.ceil(dsInfo.width / factor), height: Math.ceil(dsInfo.height / factor), resX: dsInfo.resX * factor, resY: dsInfo.resY * factor }; } for(let j = tminx; j <= tmaxx; j++){ await promises.mkdir(node_path.join(outputDir, tz.toString(), j.toString()), { recursive: true }); for(let i = tminy; i <= tmaxy; i++){ const tileBound = ST_TileEnvelope(tz, j, i, buffer, this.tileBoundTool); const { rb, wb } = geoQuery(overviewInfo, tileBound[0], tileBound[3], tileBound[2], tileBound[1], outTileSize); const createInfo = { outTileSize, overviewInfo, rb, wb, encoding, dsPath: dsInfo.path, x: j, y: i, z: tz, outputTile: outputDir, type }; tileCount++; queue.add(()=>this.pool.run(createInfo)); } } } log(` - \u{6B65}\u{9AA4}${++stepIndex}: \u{751F}\u{6210}\u{5207}\u{7247}\u{4EFB}\u{52A1}\u{961F}\u{5217}: ${tileCount} - \u{5B8C}\u{6210}`); queue.start(); } resetStats() { this.statistics.tileCount = 0; this.statistics.completeCount = 0; this.statistics.levelInfo = {}; } } function geoQuery(overviewInfo, ulx, uly, lrx, lry, querysize = 0) { const { startX, startY, width, height, resX, resY } = overviewInfo; let rx = Math.floor((ulx - startX) / resX + 0.001); let ry = Math.floor((uly - startY) / resY + 0.001); let rxsize = Math.max(1, Math.floor((lrx - ulx) / resX + 0.5)); let rysize = Math.max(1, Math.floor((lry - uly) / resY + 0.5)); let wxsize, wysize; if (querysize) { wxsize = querysize; wysize = querysize; } else { wxsize = rxsize; wysize = rysize; } let wx = 0; if (rx < 0) { const rxshift = Math.abs(rx); wx = Math.floor(rxshift / rxsize * wxsize); wxsize -= wx; rxsize -= Math.floor(rxshift / rxsize * rxsize); rx = 0; } if (rx + rxsize > width) { wxsize = Math.floor(wxsize * (width - rx) * 1.0 / rxsize); rxsize = width - rx; } let wy = 0; if (ry < 0) { const ryshift = Math.abs(ry); wy = Math.floor(ryshift / rysize * wysize); wysize -= wy; rysize -= Math.floor(ryshift / rysize * rysize); ry = 0; } if (ry + rysize > height) { wysize = Math.floor(wysize * (height - ry) * 1.0 / rysize); rysize = height - ry; } return { rb: { rx, ry, rxsize, rysize }, wb: { wx, wy, wxsize, wysize } }; } export { TIF2Tiles };