356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* sat-ortho.mjs
|
|
*
|
|
* Fetches satellite tiles from the Google Map Tiles API and stitches them
|
|
* into a single ortho image centered on a given lat/lon.
|
|
*
|
|
* Usage:
|
|
* node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719
|
|
* node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719 --zoom 21 --size 2048
|
|
* node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719 --zoom 22 --size 2048 --outdir output/
|
|
*
|
|
* Options:
|
|
* --lat Center latitude (required)
|
|
* --lon Center longitude (required)
|
|
* --zoom Tile zoom level (default: 21, max typically 22)
|
|
* --size Output image size in pixels, square (default: 2048)
|
|
* --outdir Output directory (default: output/)
|
|
* --key Google Maps API key (default: reads from .env)
|
|
*
|
|
* How it works:
|
|
* 1. Creates a Map Tiles API session for satellite imagery.
|
|
* 2. Computes which tiles cover a region centered on the target lat/lon,
|
|
* sized to produce at least --size x --size pixels of coverage.
|
|
* 3. Downloads all tiles in parallel (with concurrency limit).
|
|
* 4. Stitches tiles into a full grid image.
|
|
* 5. Crops to --size x --size centered on the exact target pixel.
|
|
* 6. Saves as JPEG.
|
|
*
|
|
* Dependencies: sharp (devDependency)
|
|
*/
|
|
|
|
import { readFileSync, mkdirSync } from 'node:fs';
|
|
import { resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import sharp from 'sharp';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const PROJECT_ROOT = resolve(__dirname, '..');
|
|
const TILE_SIZE = 256;
|
|
const MAX_CONCURRENT = 8;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Argument parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const opts = { zoom: 21, size: 2048, outdir: 'output' };
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case '--lat': opts.lat = parseFloat(args[++i]); break;
|
|
case '--lon': opts.lon = parseFloat(args[++i]); break;
|
|
case '--zoom': opts.zoom = parseInt(args[++i], 10); break;
|
|
case '--size': opts.size = parseInt(args[++i], 10); break;
|
|
case '--outdir': opts.outdir = args[++i]; break;
|
|
case '--key': opts.key = args[++i]; break;
|
|
case '--help':
|
|
console.log([
|
|
'Usage: node tools/sat-ortho.mjs --lat <lat> --lon <lon> [options]',
|
|
'',
|
|
'Required:',
|
|
' --lat Center latitude',
|
|
' --lon Center longitude',
|
|
'',
|
|
'Options:',
|
|
' --zoom Tile zoom level (default: 21)',
|
|
' --size Output square size in pixels (default: 2048)',
|
|
' --outdir Output directory (default: output/)',
|
|
' --key Google Maps API key',
|
|
].join('\n'));
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
if (opts.lat === undefined || opts.lon === undefined) {
|
|
console.error('Error: --lat and --lon are required.');
|
|
process.exit(1);
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Load API key
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadApiKey(overrideKey) {
|
|
if (overrideKey) return overrideKey;
|
|
if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY;
|
|
|
|
try {
|
|
const envPath = join(PROJECT_ROOT, '.env');
|
|
const envContent = readFileSync(envPath, 'utf8');
|
|
for (const line of envContent.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
const val = trimmed.slice(eqIdx + 1).trim();
|
|
if (key === 'GOOGLE_MAPS_API_KEY' && val) return val;
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
console.error('Error: No API key found. Set GOOGLE_MAPS_API_KEY in .env or pass --key.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tile math
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Convert lat/lon to fractional tile coordinates at a given zoom level. */
|
|
function latLonToTile(lat, lon, zoom) {
|
|
const n = 2 ** zoom;
|
|
const x = ((lon + 180) / 360) * n;
|
|
const latRad = lat * Math.PI / 180;
|
|
const y = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n;
|
|
return { x, y };
|
|
}
|
|
|
|
/** Convert tile x/y (integer) + pixel offset back to lat/lon. */
|
|
function tilePxToLatLon(tileX, tileY, pxX, pxY, zoom) {
|
|
const n = 2 ** zoom;
|
|
const lon = ((tileX + pxX / TILE_SIZE) / n) * 360 - 180;
|
|
const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (tileY + pxY / TILE_SIZE) / n)));
|
|
const lat = latRad * 180 / Math.PI;
|
|
return { lat, lon };
|
|
}
|
|
|
|
/** Compute meters per pixel at a given latitude and zoom. */
|
|
function metersPerPixel(lat, zoom) {
|
|
return (156543.03392 * Math.cos(lat * Math.PI / 180)) / (2 ** zoom);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tile fetching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createSession(apiKey) {
|
|
const res = await fetch(`https://tile.googleapis.com/v1/createSession?key=${apiKey}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Referer': 'http://localhost:4173/',
|
|
},
|
|
body: JSON.stringify({
|
|
mapType: 'satellite',
|
|
language: 'en-US',
|
|
region: 'US',
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Failed to create tile session: ${res.status} ${text}`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
return data.session;
|
|
}
|
|
|
|
async function fetchTile(zoom, x, y, session, apiKey) {
|
|
const url = `https://tile.googleapis.com/v1/2dtiles/${zoom}/${x}/${y}?session=${session}&key=${apiKey}`;
|
|
const res = await fetch(url, {
|
|
headers: { 'Referer': 'http://localhost:4173/' },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Tile ${zoom}/${x}/${y} failed: ${res.status}`);
|
|
}
|
|
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
/**
|
|
* Fetch multiple tiles with a concurrency limit.
|
|
* @param {Array<{zoom, x, y}>} tileCoords
|
|
* @param {string} session
|
|
* @param {string} apiKey
|
|
* @returns {Map<string, Buffer>} key: "x,y" → JPEG buffer
|
|
*/
|
|
async function fetchTiles(tileCoords, session, apiKey) {
|
|
const results = new Map();
|
|
let idx = 0;
|
|
let completed = 0;
|
|
const total = tileCoords.length;
|
|
|
|
async function worker() {
|
|
while (idx < tileCoords.length) {
|
|
const i = idx++;
|
|
const { zoom, x, y } = tileCoords[i];
|
|
try {
|
|
const buf = await fetchTile(zoom, x, y, session, apiKey);
|
|
results.set(`${x},${y}`, buf);
|
|
} catch (err) {
|
|
console.log(` Warning: tile ${zoom}/${x}/${y} failed: ${err.message}`);
|
|
}
|
|
completed++;
|
|
if (completed % 10 === 0 || completed === total) {
|
|
process.stdout.write(`\r ${completed}/${total} tiles fetched`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const workers = [];
|
|
for (let w = 0; w < Math.min(MAX_CONCURRENT, total); w++) {
|
|
workers.push(worker());
|
|
}
|
|
await Promise.all(workers);
|
|
console.log();
|
|
|
|
return results;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
const opts = parseArgs();
|
|
const apiKey = loadApiKey(opts.key);
|
|
|
|
const mpp = metersPerPixel(opts.lat, opts.zoom);
|
|
const coverageM = mpp * opts.size;
|
|
|
|
console.log('\nSatellite Ortho Stitcher');
|
|
console.log(` Center : ${opts.lat}, ${opts.lon}`);
|
|
console.log(` Zoom : ${opts.zoom}`);
|
|
console.log(` Output : ${opts.size}x${opts.size} px`);
|
|
console.log(` GSD : ~${(mpp * 100).toFixed(1)} cm/pixel`);
|
|
console.log(` Coverage : ~${coverageM.toFixed(0)}m x ${coverageM.toFixed(0)}m`);
|
|
|
|
// Compute center tile position (fractional)
|
|
const center = latLonToTile(opts.lat, opts.lon, opts.zoom);
|
|
const centerTileX = Math.floor(center.x);
|
|
const centerTileY = Math.floor(center.y);
|
|
const centerPxX = (center.x - centerTileX) * TILE_SIZE; // pixel offset within center tile
|
|
const centerPxY = (center.y - centerTileY) * TILE_SIZE;
|
|
|
|
// How many tiles we need on each side to cover --size/2 pixels from center
|
|
const halfSize = opts.size / 2;
|
|
const tilesLeft = Math.ceil((halfSize - centerPxX) / TILE_SIZE) + 1;
|
|
const tilesRight = Math.ceil((halfSize - (TILE_SIZE - centerPxX)) / TILE_SIZE) + 1;
|
|
const tilesUp = Math.ceil((halfSize - centerPxY) / TILE_SIZE) + 1;
|
|
const tilesDown = Math.ceil((halfSize - (TILE_SIZE - centerPxY)) / TILE_SIZE) + 1;
|
|
|
|
const minTileX = centerTileX - tilesLeft;
|
|
const maxTileX = centerTileX + tilesRight;
|
|
const minTileY = centerTileY - tilesUp;
|
|
const maxTileY = centerTileY + tilesDown;
|
|
|
|
const gridW = maxTileX - minTileX + 1;
|
|
const gridH = maxTileY - minTileY + 1;
|
|
const totalTiles = gridW * gridH;
|
|
|
|
console.log(` Grid : ${gridW}x${gridH} = ${totalTiles} tiles`);
|
|
console.log(` Tile range: x=[${minTileX}, ${maxTileX}] y=[${minTileY}, ${maxTileY}]`);
|
|
|
|
// Create session
|
|
console.log('\nCreating tile session...');
|
|
const session = await createSession(apiKey);
|
|
|
|
// Build tile coordinate list
|
|
const tileCoords = [];
|
|
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
|
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
|
tileCoords.push({ zoom: opts.zoom, x: tx, y: ty });
|
|
}
|
|
}
|
|
|
|
// Fetch all tiles
|
|
console.log(` Fetching ${totalTiles} tiles (concurrency: ${MAX_CONCURRENT})...`);
|
|
const t0 = performance.now();
|
|
const tiles = await fetchTiles(tileCoords, session, apiKey);
|
|
const fetchElapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
|
console.log(` Fetched ${tiles.size}/${totalTiles} tiles in ${fetchElapsed}s`);
|
|
|
|
if (tiles.size === 0) {
|
|
console.error('Error: No tiles fetched.');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Stitch tiles into a full grid image
|
|
console.log('\nStitching...');
|
|
const stitchW = gridW * TILE_SIZE;
|
|
const stitchH = gridH * TILE_SIZE;
|
|
|
|
// Build composite input array for sharp
|
|
const compositeInputs = [];
|
|
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
|
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
|
const key = `${tx},${ty}`;
|
|
const buf = tiles.get(key);
|
|
if (!buf) continue;
|
|
const left = (tx - minTileX) * TILE_SIZE;
|
|
const top = (ty - minTileY) * TILE_SIZE;
|
|
compositeInputs.push({ input: buf, left, top });
|
|
}
|
|
}
|
|
|
|
// Create blank canvas and composite all tiles
|
|
let stitched = sharp({
|
|
create: {
|
|
width: stitchW,
|
|
height: stitchH,
|
|
channels: 3,
|
|
background: { r: 0, g: 0, b: 0 },
|
|
},
|
|
}).jpeg();
|
|
|
|
stitched = sharp(await stitched.composite(compositeInputs).toBuffer());
|
|
|
|
// Crop to --size x --size centered on the target lat/lon
|
|
const cropLeft = Math.round((centerTileX - minTileX) * TILE_SIZE + centerPxX - halfSize);
|
|
const cropTop = Math.round((centerTileY - minTileY) * TILE_SIZE + centerPxY - halfSize);
|
|
|
|
const cropped = await stitched
|
|
.extract({
|
|
left: Math.max(0, cropLeft),
|
|
top: Math.max(0, cropTop),
|
|
width: opts.size,
|
|
height: opts.size,
|
|
})
|
|
.jpeg({ quality: 92 })
|
|
.toBuffer();
|
|
|
|
// Save
|
|
const outdir = resolve(PROJECT_ROOT, opts.outdir);
|
|
mkdirSync(outdir, { recursive: true });
|
|
|
|
const outName = `sat_${opts.lat}_${opts.lon}_z${opts.zoom}_${opts.size}x${opts.size}.jpg`;
|
|
const outPath = join(outdir, outName);
|
|
await sharp(cropped).toFile(outPath);
|
|
|
|
const totalElapsed = ((performance.now() - t0) / 1000).toFixed(2);
|
|
console.log(`\nSaved: ${outPath}`);
|
|
console.log(` Size : ${opts.size}x${opts.size}`);
|
|
console.log(` GSD : ~${(mpp * 100).toFixed(1)} cm/pixel`);
|
|
console.log(` Coverage : ~${coverageM.toFixed(0)}m x ${coverageM.toFixed(0)}m`);
|
|
console.log(` Tiles : ${tiles.size}`);
|
|
console.log(` Time : ${totalElapsed}s`);
|
|
|
|
// Report corner coordinates
|
|
const topLeft = tilePxToLatLon(minTileX, minTileY, cropLeft - 0, cropTop - 0, opts.zoom);
|
|
const botRight = tilePxToLatLon(minTileX, minTileY, cropLeft + opts.size, cropTop + opts.size, opts.zoom);
|
|
console.log(` NW corner: ${topLeft.lat.toFixed(6)}, ${topLeft.lon.toFixed(6)}`);
|
|
console.log(` SE corner: ${botRight.lat.toFixed(6)}, ${botRight.lon.toFixed(6)}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('\nError:', err.message);
|
|
process.exit(1);
|
|
});
|