Release God's Eye View as open source

This commit is contained in:
Bilawal Sidhu committed 2026-08-24 17:02:16 -05:00
1 parent a5aaea7e16
commit 880a672b5e
433 files changed
+219893 -23

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
# Tools
CLI scripts for fetching, rendering, and reprojecting geospatial imagery. All tools read the Google Maps API key from `.env` automatically.
Output files go to `output/` by default (gitignored).
## Prerequisites
- Node.js (via `mise`)
- `sharp` and `puppeteer` (devDependencies — `npm install`)
- Google Maps API key in `.env` as `GOOGLE_MAPS_API_KEY`
- APIs enabled on your Google Cloud project: **Map Tiles API**, **Street View Static API**
---
## sat-ortho.mjs
Stitches satellite tiles from the Map Tiles API into a single ortho image centered on a lat/lon.
```sh
# 2K ortho at zoom 21 (~6.4 cm/pixel, ~132m coverage)
node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719
# Zoom 20 for wider coverage (~264m), or zoom 22 for max detail (~3.7 cm/pixel)
node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719 --zoom 20 --size 2048
node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719 --zoom 22 --size 2048
```
| Option | Default | Description |
|--------|---------|-------------|
| `--lat` | — | Center latitude (required) |
| `--lon` | — | Center longitude (required) |
| `--zoom` | 21 | Tile zoom level (max typically 22) |
| `--size` | 2048 | Output square size in pixels |
Reports GSD, ground coverage, and NW/SE corner coordinates for georeferencing.
---
## streetview-headings.mjs
Fetches 8 Street View static images (one per compass heading) at a location, with optional first-order neighbor traversal.
```sh
# 8 images at the intersection
node tools/streetview-headings.mjs --lat 30.266476 --lon -97.73719
# Include all first-order neighbor locations (auto-deduped)
node tools/streetview-headings.mjs --lat 30.266476 --lon -97.73719 --neighbors
# Custom FOV and pitch
node tools/streetview-headings.mjs --lat 30.266476 --lon -97.73719 --fov 120 --pitch -10
```
| Option | Default | Description |
|--------|---------|-------------|
| `--lat` | — | Latitude (required) |
| `--lon` | — | Longitude (required) |
| `--fov` | 90 | Field of view in degrees (10-120) |
| `--pitch` | 0 | Camera pitch (-90 to 90) |
| `--size` | 640x640 | Image size (max 640 per axis) |
| `--step` | 45 | Heading step in degrees |
| `--neighbors` | off | Also fetch images from nearby Street View locations |
The `--neighbors` flag queries Google's panorama metadata for linked locations, deduplicates them by pano ID via the metadata API, and fetches 8 heading images from each unique neighbor within 15m.
---
## streetview-panorama.mjs
Fetches and stitches a full equirectangular panorama from the Map Tiles API (Street View tiles endpoint).
```sh
# Default zoom 3 → 4096x2048 panorama
node tools/streetview-panorama.mjs --lat 30.266476 --lon -97.73719
# Max resolution (zoom 5 → up to 16384x8192)
node tools/streetview-panorama.mjs --lat 30.266476 --lon -97.73719 --zoom 5
```
| Option | Default | Description |
|--------|---------|-------------|
| `--lat` | — | Latitude (required) |
| `--lon` | — | Longitude (required) |
| `--zoom` | 3 | Tile zoom 0-5 (higher = larger pano) |
| `--radius` | 50 | Search radius in meters for nearest panorama |
Output is a standard equirectangular JPEG suitable as input for `pano-pinhole.mjs`.
---
## pano-pinhole.mjs
Reprojects an equirectangular panorama into pinhole (perspective) camera views. Uses inverse mapping with bilinear interpolation — every output pixel is sampled from the panorama, so there are no holes.
```sh
# Single view: looking west, 90 deg FOV, 1920x1080
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 270 --hfov 90
# All 8 compass headings from one panorama
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --all --hfov 90
# 2K output with narrow FOV
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 180 --hfov 60 --width 2560 --height 1440
# Specify focal length instead of FOV
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 90 --focal 2000
# 12 views at 30-degree steps
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --all --step 30 --hfov 90
```
| Option | Default | Description |
|--------|---------|-------------|
| `--input` | — | Equirectangular panorama JPEG (required) |
| `--heading` | 0 | Compass heading (0=N, 90=E, 180=S, 270=W) |
| `--pitch` | 0 | Pitch in degrees (positive=up) |
| `--roll` | 0 | Roll in degrees (positive=clockwise) |
| `--hfov` | 90 | Horizontal field of view in degrees |
| `--focal` | — | Focal length in pixels (overrides `--hfov`) |
| `--width` | 1920 | Output width |
| `--height` | 1080 | Output height |
| `--all` | off | Render all compass headings (ignores `--heading`) |
| `--step` | 45 | Heading step for `--all` mode |
Reports horizontal, vertical, and diagonal FOV plus equivalent focal length.
---
## cesium-render.mjs
Renders a CesiumJS 3D view to JPEG via headless Chromium with Google Photorealistic 3D tiles. Requires SwiftShader (no GPU).
```sh
# Look-at mode: camera looks at a target point from above
node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --heading 180 --pitch -30 --height 25
# Top-down view at 80m
node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --pitch -90 --height 80 --fov 90
# 2K output
node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --heading 180 --pitch -30 --height 25 --width 2560 --height-px 1440
# Direct mode: camera placed at coordinates
node tools/cesium-render.mjs --lat 30.266476 --lon -97.73719 --heading 270 --pitch -15 --height 8
```
| Option | Default | Description |
|--------|---------|-------------|
| `--lat/--lon` | — | Camera position (direct mode) |
| `--lookat-lat/--lookat-lon` | — | Target to look at (lookat mode) |
| `--heading` | 0 | Compass heading |
| `--pitch` | -10 | Camera pitch (must be negative in lookat mode) |
| `--height` | 8 | Meters above ground |
| `--fov` | 60 | Vertical field of view |
| `--width` | 1280 | Image width |
| `--height-px` | 720 | Image height |
| `--sse` | 2 | Screen-space error (lower = sharper, slower) |
| `--timeout` | 30 | Max wait in seconds |
Uses progressive SSE refinement and automatic ground-height sampling. Street-level views are limited by Google's photogrammetry tile resolution.
---
## Typical Workflow
```sh
# 1. Get a satellite ortho of an intersection
node tools/sat-ortho.mjs --lat 30.266476 --lon -97.73719 --zoom 21
# 2. Fetch a 4K panorama at the same location
node tools/streetview-panorama.mjs --lat 30.266476 --lon -97.73719
# 3. Extract 8 pinhole views from the panorama
node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --all --hfov 90
# 4. Also grab the Street View static API versions for comparison
node tools/streetview-headings.mjs --lat 30.266476 --lon -97.73719
# 5. Render a 3D view of the same location from above
node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --pitch -90 --height 50 --fov 90 --width 2560 --height-px 1440
```
+271
View File
@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
html, body, #cesiumContainer { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
/* Hide all Cesium UI widgets */
.cesium-viewer-toolbar,
.cesium-viewer-bottom,
.cesium-viewer-fullscreenContainer,
.cesium-viewer-infoBoxContainer,
.cesium-viewer-selectionIndicatorContainer,
.cesium-viewer-geocoderContainer,
.cesium-credit-logoContainer,
.cesium-credit-textContainer { display: none !important; }
</style>
<link rel="stylesheet" href="/cesium/Widgets/widgets.css">
<script src="/cesium/Cesium.js"></script>
</head>
<body>
<div id="cesiumContainer"></div>
<script>
window.__tilesReady = false;
window.__error = null;
window.__groundHeight = null;
window.__cameraInfo = null;
(async function() {
try {
const params = new URLSearchParams(window.location.search);
const mode = params.get('mode') || 'direct';
const heading = parseFloat(params.get('heading') || '0');
const pitch = parseFloat(params.get('pitch') || '-10');
const height = parseFloat(params.get('height') || '8');
const fov = parseFloat(params.get('fov') || '60');
const sse = parseFloat(params.get('sse') || '2');
const apiKey = params.get('key');
if (!apiKey) throw new Error('Missing API key');
// In direct mode: lat/lon is the camera position, target = camera.
// In lookat mode: targetLat/targetLon is the point we look at,
// camera position is computed by CesiumJS after ground height sampling.
let lat, lon, targetLat, targetLon;
if (mode === 'lookat') {
targetLat = parseFloat(params.get('targetLat'));
targetLon = parseFloat(params.get('targetLon'));
if (isNaN(targetLat) || isNaN(targetLon)) throw new Error('Missing targetLat/targetLon');
} else {
lat = parseFloat(params.get('lat'));
lon = parseFloat(params.get('lon'));
if (isNaN(lat) || isNaN(lon)) throw new Error('Missing lat/lon');
targetLat = lat;
targetLon = lon;
}
// Create viewer with minimal UI, continuous rendering
const viewer = new Cesium.Viewer('cesiumContainer', {
timeline: false,
animation: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
creditContainer: document.createElement('div'),
requestRenderMode: false,
msaaSamples: 1,
useBrowserRecommendedResolution: true,
});
// Disable globe (Google tiles replace it) and atmosphere
viewer.imageryLayers.removeAll();
viewer.scene.fog.enabled = false;
viewer.scene.globe.show = false;
viewer.scene.skyAtmosphere.show = false;
// Set FOV
viewer.camera.frustum.fov = Cesium.Math.toRadians(fov);
// Expose viewer globally for the Node script to reference
window.__viewer = viewer;
// Add Google 3D Photorealistic tiles
const tileset = await Cesium.createGooglePhotorealistic3DTileset(apiKey);
window.__tileset = tileset;
tileset.maximumScreenSpaceError = 16; // start coarse
tileset.maximumMemoryUsage = 2048;
// Increase concurrent tile requests to speed up loading
tileset.maximumNumberOfLoadedTiles = 5000;
viewer.scene.primitives.add(tileset);
// Helper: position camera for rendering.
function setCamera(groundH) {
const cameraH = groundH + height; // WGS84 ellipsoid height
if (mode === 'lookat') {
// Compute camera position using CesiumJS geodesic math.
// Camera is placed behind the heading direction at the distance
// where the pitch ray hits the target ground point.
const DEG2RAD = Cesium.Math.RADIANS_PER_DEGREE;
const pitchRad = Math.abs(pitch) * DEG2RAD;
const horizDist = height / Math.tan(pitchRad);
const reverseBearing = ((heading + 180) % 360) * DEG2RAD;
// Use CesiumJS destinationPoint for accurate ellipsoid offset
const targetCart = Cesium.Cartographic.fromDegrees(targetLon, targetLat);
const geodesic = new Cesium.EllipsoidGeodesic();
geodesic.setEndPoints(targetCart, targetCart); // just to init
// Compute camera position by offsetting from target
const dx = horizDist * Math.sin(reverseBearing); // east meters
const dy = horizDist * Math.cos(reverseBearing); // north meters
const camLat = targetLat + dy / 111320;
const camLon = targetLon + dx / (111320 * Math.cos(targetLat * DEG2RAD));
// Now use EllipsoidGeodesic to get the TRUE heading and pitch
// from the computed camera position to the target
const camCart = Cesium.Cartographic.fromDegrees(camLon, camLat);
const trueGeodesic = new Cesium.EllipsoidGeodesic(camCart, targetCart);
const trueHeading = trueGeodesic.startHeading; // radians
const trueDist = trueGeodesic.surfaceDistance; // meters
const truePitch = Math.atan2(-height, trueDist); // negative
lat = camLat;
lon = camLon;
window.__cameraInfo = {
lat: camLat,
lon: camLon,
height: groundH + height,
heading: Cesium.Math.toDegrees(trueHeading),
pitch: Cesium.Math.toDegrees(truePitch),
dist: trueDist,
};
console.log('Camera: ' + camLat.toFixed(6) + ', ' + camLon.toFixed(6) +
' heading=' + window.__cameraInfo.heading.toFixed(1) +
' pitch=' + window.__cameraInfo.pitch.toFixed(1) +
' dist=' + trueDist.toFixed(1) + 'm');
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(camLon, camLat, cameraH),
orientation: {
heading: trueHeading,
pitch: truePitch,
roll: 0,
},
});
} else {
// Direct mode: place camera at lat/lon
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, cameraH),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: 0,
},
});
}
}
// Helper: pump N render frames
function pumpFrames(n) {
return new Promise(resolve => {
let count = 0;
function go() {
viewer.scene.requestRender();
if (++count >= n) { resolve(); return; }
requestAnimationFrame(go);
}
requestAnimationFrame(go);
});
}
// Helper: wait until tilesLoaded is true for stableMs, or maxMs elapsed
function waitStable(stableMs, maxMs) {
return new Promise(resolve => {
const t0 = Date.now();
let loadedSince = null;
function check() {
viewer.scene.requestRender();
const now = Date.now();
if (tileset.tilesLoaded) {
if (!loadedSince) loadedSince = now;
if (now - loadedSince >= stableMs) { resolve(true); return; }
} else {
loadedSince = null;
}
if (now - t0 > maxMs) { resolve(false); return; }
requestAnimationFrame(check);
}
requestAnimationFrame(check);
});
}
// ---------------------------------------------------------------
// Phase 1: Overhead — get ground height
// ---------------------------------------------------------------
// Use target coords for overhead + ground sampling (same as camera
// in direct mode, but the actual target in lookat mode).
console.log('Phase 1: overhead tile load for ground height...');
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(targetLon, targetLat, 500),
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
});
await waitStable(1000, 15000);
// Sample ground height at the TARGET position
let groundHeight;
const probeOffsets = [
[0, 0], [0.0001, 0], [-0.0001, 0], [0, 0.0001], [0, -0.0001],
[0.0002, 0], [-0.0002, 0], [0, 0.0002], [0, -0.0002],
];
for (const [dLat, dLon] of probeOffsets) {
const c = Cesium.Cartographic.fromDegrees(targetLon + dLon, targetLat + dLat);
const h = viewer.scene.sampleHeight(c);
if (h !== undefined && !isNaN(h)) { groundHeight = h; break; }
}
if (groundHeight === undefined) {
console.warn('sampleHeight failed, using 150m fallback');
groundHeight = 150;
}
window.__groundHeight = groundHeight;
console.log('Ground: ' + groundHeight.toFixed(1) + 'm Camera: ' + (groundHeight + height).toFixed(1) + 'm');
// ---------------------------------------------------------------
// Phase 2: Street-level — progressive SSE refinement
// ---------------------------------------------------------------
console.log('Phase 2: street-level tile loading...');
setCamera(groundHeight);
// Step SSE down progressively. At each step, wait for tiles to
// become stable before requesting more detail. This avoids
// flooding SwiftShader with thousands of tiles at once.
const steps = [16, 12, 8, 6, 4, sse];
// Remove duplicates and sort descending
const uniqueSteps = [...new Set(steps)].filter(s => s >= sse).sort((a, b) => b - a);
for (const s of uniqueSteps) {
tileset.maximumScreenSpaceError = s;
console.log(' SSE=' + s + '...');
// Give lower SSE levels more time — they need more tiles
const maxWait = s <= 2 ? 90000 : s <= 4 ? 60000 : 30000;
const stable = await waitStable(3000, maxWait);
const stats = tileset._statistics || {};
const loaded = stats.numberOfLoadedTilesTotal || '?';
const commands = stats.numberOfCommands || '?';
const triangles = stats.numberOfTrianglesSelected || '?';
console.log(' ' + (stable ? 'stable' : 'timeout') +
', loaded=' + loaded + ', cmds=' + commands + ', tris=' + triangles);
}
// ---------------------------------------------------------------
// Phase 3: Final settle
// ---------------------------------------------------------------
console.log('Phase 3: settle...');
await pumpFrames(180); // ~3s of render frames
window.__tilesReady = true;
console.log('Ready.');
} catch (err) {
window.__error = err.message || String(err);
console.error('Cesium render error:', err);
}
})();
</script>
</body>
</html>
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* cesium-render.mjs
*
* Renders a CesiumJS 3D view to a JPEG image using headless Chromium
* (Puppeteer) with Google 3D Photorealistic tiles.
*
* Two modes:
*
* Direct position (camera placed at lat/lon):
* node tools/cesium-render.mjs --lat 30.266476 --lon -97.73719 --heading 270 --pitch -15 --height 8
*
* Look-at mode (camera computed to look at a target point):
* node tools/cesium-render.mjs --lookat-lat 30.266476 --lookat-lon -97.73719 --heading 270 --pitch -30 --height 20
*
* In look-at mode the camera is positioned behind the heading direction at
* the distance needed so the target point is centered in view. The --height
* is meters above ground at the *target* point, and --pitch must be negative
* (looking down).
*
* Options:
* --lat Camera latitude (direct mode)
* --lon Camera longitude (direct mode)
* --lookat-lat Target latitude to look at (lookat mode)
* --lookat-lon Target longitude to look at (lookat mode)
* --heading Compass heading in degrees, 0=N 90=E 180=S 270=W (default: 0)
* --pitch Camera pitch in degrees, 0=horizon -90=down (default: -10)
* --height Height above ground in meters (default: 8)
* --fov Vertical field of view in degrees (default: 60)
* --width Image width in pixels (default: 1280)
* --height-px Image height in pixels (default: 720)
* --sse Max screen-space error, lower = sharper (default: 2)
* --timeout Max total wait in seconds (default: 30)
* --outdir Output directory (default: output/)
* --key Google Maps API key (default: reads from .env)
*
* Strategy:
* 1. Overhead pass (500m up, looking down) to sample ground elevation.
* 2. Position camera at target level (ground + --height meters).
* 3. Progressive SSE refinement: step from SSE=16 down to target SSE,
* waiting for each LOD level to fully load before requesting more detail.
* 4. 3-second settle pass for final texture uploads.
* 5. Screenshot.
*
* Dependencies: puppeteer, cesium (for serving assets)
*/
import { readFileSync, mkdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'node:http';
import puppeteer from 'puppeteer';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
heading: 0, pitch: -10, height: 8, fov: 60,
width: 1280, heightPx: 720, sse: 2, timeout: 30, 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 '--lookat-lat': opts.lookatLat = parseFloat(args[++i]); break;
case '--lookat-lon': opts.lookatLon = parseFloat(args[++i]); break;
case '--heading': opts.heading = parseFloat(args[++i]); break;
case '--pitch': opts.pitch = parseFloat(args[++i]); break;
case '--height': opts.height = parseFloat(args[++i]); break;
case '--fov': opts.fov = parseFloat(args[++i]); break;
case '--width': opts.width = parseInt(args[++i], 10); break;
case '--height-px': opts.heightPx = parseInt(args[++i], 10); break;
case '--sse': opts.sse = parseFloat(args[++i]); break;
case '--timeout': opts.timeout = parseInt(args[++i], 10); break;
case '--outdir': opts.outdir = args[++i]; break;
case '--key': opts.key = args[++i]; break;
case '--help':
console.log([
'Usage:',
' Direct: node tools/cesium-render.mjs --lat <lat> --lon <lon> [options]',
' LookAt: node tools/cesium-render.mjs --lookat-lat <lat> --lookat-lon <lon> [options]',
'',
'Position (pick one pair):',
' --lat Camera latitude (direct mode)',
' --lon Camera longitude (direct mode)',
' --lookat-lat Target latitude to look at (computes camera position)',
' --lookat-lon Target longitude to look at (computes camera position)',
'',
'Camera:',
' --heading Compass heading (default: 0)',
' --pitch Camera pitch (default: -10, must be <0 for lookat mode)',
' --height Meters above ground (default: 8)',
' --fov Vertical FOV degrees (default: 60)',
'',
'Output:',
' --width Image width (default: 1280)',
' --height-px Image height (default: 720)',
' --sse Screen space error, lower=sharper (default: 2)',
' --timeout Timeout seconds (default: 30)',
' --outdir Output dir (default: output/)',
' --key Google Maps API key',
].join('\n'));
process.exit(0);
}
}
// Validate: need either --lat/--lon or --lookat-lat/--lookat-lon, not both
const hasDirect = opts.lat !== undefined && opts.lon !== undefined;
const hasLookat = opts.lookatLat !== undefined && opts.lookatLon !== undefined;
const hasPartialDirect = (opts.lat !== undefined) !== (opts.lon !== undefined);
const hasPartialLookat = (opts.lookatLat !== undefined) !== (opts.lookatLon !== undefined);
if (hasPartialDirect) {
console.error('Error: --lat and --lon must both be provided.');
process.exit(1);
}
if (hasPartialLookat) {
console.error('Error: --lookat-lat and --lookat-lon must both be provided.');
process.exit(1);
}
if (hasDirect && hasLookat) {
console.error('Error: Use --lat/--lon OR --lookat-lat/--lookat-lon, not both.');
process.exit(1);
}
if (!hasDirect && !hasLookat) {
console.error('Error: Provide --lat/--lon (direct) or --lookat-lat/--lookat-lon (look-at).');
process.exit(1);
}
if (hasLookat) {
opts.mode = 'lookat';
if (opts.pitch >= 0) {
console.error('Error: --pitch must be negative in lookat mode (camera looks down at target).');
process.exit(1);
}
} else {
opts.mode = 'direct';
}
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);
}
// ---------------------------------------------------------------------------
// Temporary HTTP server to serve Cesium assets + render page
// ---------------------------------------------------------------------------
function startServer() {
const CESIUM_ROOT = join(PROJECT_ROOT, 'node_modules/cesium/Build/Cesium');
const RENDER_HTML = join(__dirname, 'cesium-render.html');
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wasm': 'application/wasm',
'.glb': 'model/gltf-binary',
};
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
let filePath;
if (url.pathname === '/favicon.ico') {
res.writeHead(204);
res.end();
return;
} else if (url.pathname === '/' || url.pathname === '/index.html') {
filePath = RENDER_HTML;
} else if (url.pathname.startsWith('/cesium/')) {
filePath = join(CESIUM_ROOT, url.pathname.slice('/cesium/'.length));
} else {
res.writeHead(404);
res.end('Not found');
return;
}
try {
const data = readFileSync(filePath);
const ext = filePath.slice(filePath.lastIndexOf('.'));
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const apiKey = loadApiKey(opts.key);
console.log(`\nCesium 3D Renderer`);
if (opts.mode === 'lookat') {
console.log(` Mode : lookat`);
console.log(` Target : ${opts.lookatLat}, ${opts.lookatLon}`);
} else {
console.log(` Mode : direct`);
console.log(` Camera : ${opts.lat}, ${opts.lon}`);
}
console.log(` Heading : ${opts.heading}° Pitch: ${opts.pitch}° Height: ${opts.height}m`);
console.log(` FOV : ${opts.fov}° Size: ${opts.width}x${opts.heightPx} SSE: ${opts.sse}`);
console.log(` Timeout : ${opts.timeout}s\n`);
// Step 1: Start temp HTTP server
console.log('Starting asset server...');
const { server, port } = await startServer();
console.log(` http://127.0.0.1:${port}\n`);
let browser;
try {
// Step 2: Launch headless Chromium with ANGLE/SwiftShader
console.log('Launching browser...');
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const page = await browser.newPage();
// Load tiles directly at target resolution.
// The HTML page does progressive SSE refinement (16→12→8→6→4→target)
// to avoid overwhelming SwiftShader with tile requests all at once.
const LOAD_W = opts.width, LOAD_H = opts.heightPx;
await page.setViewport({ width: LOAD_W, height: LOAD_H });
// Log browser console
page.on('console', msg => {
const type = msg.type();
if (type === 'error' || type === 'warning' || type === 'log') {
console.log(` [${type}] ${msg.text()}`);
}
});
// Step 3: Navigate to render page with params
const urlParams = new URLSearchParams({
heading: opts.heading,
pitch: opts.pitch,
height: opts.height,
fov: opts.fov,
sse: opts.sse,
key: apiKey,
mode: opts.mode,
});
if (opts.mode === 'lookat') {
// Lookat mode: HTML page computes camera position from target + heading/pitch/height
urlParams.set('targetLat', opts.lookatLat);
urlParams.set('targetLon', opts.lookatLon);
} else {
// Direct mode: camera placed at lat/lon
urlParams.set('lat', opts.lat);
urlParams.set('lon', opts.lon);
}
const url = `http://127.0.0.1:${port}/?${urlParams}`;
console.log(`Loading Cesium scene (${LOAD_W}x${LOAD_H})...`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Step 4: Wait for tiles to load
console.log('Waiting for tiles...');
const timeoutMs = opts.timeout * 1000;
try {
await page.waitForFunction(
'window.__tilesReady === true || window.__error !== null',
{ timeout: timeoutMs, polling: 500 }
);
} catch {
console.log(` Timed out after ${opts.timeout}s — capturing what we have.`);
}
// Check for errors
const error = await page.evaluate('window.__error');
if (error) throw new Error(`Cesium error: ${error}`);
const ready = await page.evaluate('window.__tilesReady');
if (ready) console.log(' Tiles loaded.');
// Step 5: Screenshot
const outdir = resolve(PROJECT_ROOT, opts.outdir);
mkdirSync(outdir, { recursive: true });
const fileLat = opts.mode === 'lookat' ? opts.lookatLat : opts.lat;
const fileLon = opts.mode === 'lookat' ? opts.lookatLon : opts.lon;
const prefix = opts.mode === 'lookat' ? 'cesium_lookat' : 'cesium';
const outPath = join(outdir,
`${prefix}_${fileLat}_${fileLon}_h${opts.heading}_p${opts.pitch}_${opts.height}m_${opts.width}x${opts.heightPx}.jpg`);
await page.screenshot({
path: outPath,
type: 'jpeg',
quality: 92,
fullPage: false,
});
console.log(`\nSaved: ${outPath}`);
// Report ground height and camera info
const gh = await page.evaluate('window.__groundHeight');
if (gh !== null) {
console.log(` Ground height: ${gh.toFixed(1)}m (WGS84 ellipsoid)`);
}
const cam = await page.evaluate('window.__cameraInfo');
if (cam) {
console.log(` Camera pos : ${cam.lat.toFixed(6)}, ${cam.lon.toFixed(6)}`);
console.log(` Camera alt : ${cam.height.toFixed(1)}m (WGS84)`);
console.log(` True heading : ${cam.heading.toFixed(1)}°`);
console.log(` True pitch : ${cam.pitch.toFixed(1)}°`);
console.log(` Distance : ${cam.dist.toFixed(1)}m to target`);
}
} finally {
if (browser) await browser.close();
server.close();
}
}
main().catch((err) => {
console.error('\nError:', err.message);
process.exit(1);
});
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env node
/**
* pano-pinhole.mjs
*
* Reprojects an equirectangular panorama image into a pinhole camera view.
* Uses inverse mapping (iterates output pixels) so every output pixel gets
* a bilinear-interpolated sample from the panorama — no holes possible.
*
* Usage:
* node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 270 --pitch -10 --hfov 90
* node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 180 --pitch 0 --hfov 60 --width 2560 --height 1440
* node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --heading 90 --focal 800 --width 1920 --height 1080
* node tools/pano-pinhole.mjs --input output/panorama_30.266476_-97.73719.jpg --all --hfov 90
*
* Options:
* --input Path to equirectangular panorama JPEG (required)
* --heading Compass heading in degrees, 0=N 90=E 180=S 270=W (default: 0)
* --pitch Camera pitch in degrees, 0=horizon, positive=up (default: 0)
* --roll Camera roll in degrees, positive=CW (default: 0)
* --hfov Horizontal field of view in degrees (default: 90)
* --focal Focal length in pixels (overrides --hfov if both given)
* --width Output width in pixels (default: 1920)
* --height Output height in pixels (default: 1080)
* --step Heading step in degrees for --all mode (default: 45)
* --all Render all compass headings (8 images at 45° steps by default)
* --outdir Output directory (default: output/)
*
* Coordinate convention:
* The equirectangular panorama has longitude 0 (center column) = North,
* +90 = East, ±180 = South, -90 = West. Latitude +90 = up (zenith),
* -90 = down (nadir). This matches Google Street View convention where
* heading 0 = North.
*
* Dependencies: sharp (devDependency)
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, join, basename, extname } 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, '..');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
heading: 0, pitch: 0, roll: 0, hfov: 90,
width: 1920, height: 1080, step: 45, all: false, outdir: 'output',
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--input': opts.input = args[++i]; break;
case '--heading': opts.heading = parseFloat(args[++i]); break;
case '--pitch': opts.pitch = parseFloat(args[++i]); break;
case '--roll': opts.roll = parseFloat(args[++i]); break;
case '--hfov': opts.hfov = parseFloat(args[++i]); break;
case '--focal': opts.focal = parseFloat(args[++i]); break;
case '--width': opts.width = parseInt(args[++i], 10); break;
case '--height': opts.height = parseInt(args[++i], 10); break;
case '--step': opts.step = parseInt(args[++i], 10); break;
case '--all': opts.all = true; break;
case '--outdir': opts.outdir = args[++i]; break;
case '--help':
console.log([
'Usage: node tools/pano-pinhole.mjs --input <panorama.jpg> [options]',
'',
'Required:',
' --input Equirectangular panorama image path',
'',
'Camera:',
' --heading Compass heading degrees, 0=N 90=E (default: 0)',
' --pitch Pitch degrees, 0=horizon +up (default: 0)',
' --roll Roll degrees, +CW (default: 0)',
' --hfov Horizontal FOV degrees (default: 90)',
' --focal Focal length in pixels (overrides --hfov)',
'',
'Output:',
' --width Output width pixels (default: 1920)',
' --height Output height pixels (default: 1080)',
' --all Render all compass headings (ignores --heading)',
' --step Heading step degrees for --all mode (default: 45)',
' --outdir Output directory (default: output/)',
].join('\n'));
process.exit(0);
}
}
if (!opts.input) {
console.error('Error: --input is required.');
process.exit(1);
}
return opts;
}
// ---------------------------------------------------------------------------
// Bilinear sample from raw pixel buffer
// ---------------------------------------------------------------------------
/**
* Sample the equirectangular panorama at fractional pixel coords (u, v)
* using bilinear interpolation. Wraps horizontally for panorama continuity.
*
* @param {Buffer} buf Raw RGB pixel buffer (3 bytes per pixel)
* @param {number} pW Panorama width
* @param {number} pH Panorama height
* @param {number} u Fractional x coord [0, pW)
* @param {number} v Fractional y coord [0, pH)
* @returns {[number, number, number]} RGB values [0-255]
*/
function sampleBilinear(buf, pW, pH, u, v) {
// Wrap u horizontally (panorama is cyclical in x)
u = ((u % pW) + pW) % pW;
// Clamp v vertically (poles don't wrap)
v = Math.max(0, Math.min(v, pH - 1.001));
const x0 = Math.floor(u);
const y0 = Math.floor(v);
const x1 = (x0 + 1) % pW; // wrap horizontally
const y1 = Math.min(y0 + 1, pH - 1);
const fx = u - x0;
const fy = v - y0;
// Four corner pixel offsets (RGB, 3 bytes per pixel)
const i00 = (y0 * pW + x0) * 3;
const i10 = (y0 * pW + x1) * 3;
const i01 = (y1 * pW + x0) * 3;
const i11 = (y1 * pW + x1) * 3;
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
const r = buf[i00] * w00 + buf[i10] * w10 + buf[i01] * w01 + buf[i11] * w11;
const g = buf[i00 + 1] * w00 + buf[i10 + 1] * w10 + buf[i01 + 1] * w01 + buf[i11 + 1] * w11;
const b = buf[i00 + 2] * w00 + buf[i10 + 2] * w10 + buf[i01 + 2] * w01 + buf[i11 + 2] * w11;
return [Math.round(r), Math.round(g), Math.round(b)];
}
// ---------------------------------------------------------------------------
// Rotation matrix construction
// ---------------------------------------------------------------------------
/**
* Build a 3x3 rotation matrix from heading, pitch, and roll (in radians).
*
* Convention (right-hand rule, Y-up world):
* - Heading (yaw): rotation about Y axis. 0=North(+Z), 90=East(+X)
* - Pitch: rotation about X axis. Positive = look up.
* - Roll: rotation about Z axis. Positive = clockwise from camera's POV.
*
* Camera forward in camera space is (0, 0, 1) = +Z.
* World axes: X=East, Y=Up, Z=North.
*
* Combined: R = Ry(heading) * Rx(-pitch) * Rz(roll)
*
* Returns a function that transforms a camera-space vector to world-space.
*/
function buildRotationMatrix(headingRad, pitchRad, rollRad) {
const ch = Math.cos(headingRad);
const sh = Math.sin(headingRad);
const cp = Math.cos(-pitchRad);
const sp = Math.sin(-pitchRad);
const cr = Math.cos(rollRad);
const sr = Math.sin(rollRad);
// Ry(heading)
// [ ch 0 sh]
// [ 0 1 0]
// [-sh 0 ch]
// Rx(-pitch)
// [1 0 0 ]
// [0 cp -sp ]
// [0 sp cp ]
// Rz(roll)
// [cr -sr 0]
// [sr cr 0]
// [ 0 0 1]
// Combined R = Ry * Rx * Rz (multiply out)
const m00 = ch * cr + sh * sp * sr;
const m01 = -ch * sr + sh * sp * cr;
const m02 = sh * cp;
const m10 = cp * sr;
const m11 = cp * cr;
const m12 = -sp;
const m20 = -sh * cr + ch * sp * sr;
const m21 = sh * sr + ch * sp * cr;
const m22 = ch * cp;
return (x, y, z) => [
m00 * x + m01 * y + m02 * z,
m10 * x + m11 * y + m12 * z,
m20 * x + m21 * y + m22 * z,
];
}
// ---------------------------------------------------------------------------
// Compass direction labels
// ---------------------------------------------------------------------------
const COMPASS_NAMES = {
0: 'N', 15: 'NNE', 30: 'NNE2', 45: 'NE', 60: 'ENE', 75: 'ENE2',
90: 'E', 105: 'ESE', 120: 'ESE2', 135: 'SE', 150: 'SSE', 165: 'SSE2',
180: 'S', 195: 'SSW', 210: 'SSW2', 225: 'SW', 240: 'WSW', 255: 'WSW2',
270: 'W', 285: 'WNW', 300: 'WNW2', 315: 'NW', 330: 'NNW', 345: 'NNW2',
};
// ---------------------------------------------------------------------------
// Render a single pinhole view from the panorama
// ---------------------------------------------------------------------------
/**
* @param {Buffer} panoBuf Raw RGB panorama pixels
* @param {number} pW Panorama width
* @param {number} pH Panorama height
* @param {number} heading Heading in degrees
* @param {number} pitch Pitch in degrees
* @param {number} roll Roll in degrees
* @param {number} focalPx Focal length in pixels
* @param {number} outW Output width
* @param {number} outH Output height
* @returns {Buffer} Raw RGB output buffer
*/
function renderView(panoBuf, pW, pH, heading, pitch, roll, focalPx, outW, outH) {
const headingRad = heading * Math.PI / 180;
const pitchRad = pitch * Math.PI / 180;
const rollRad = roll * Math.PI / 180;
const rotate = buildRotationMatrix(headingRad, pitchRad, rollRad);
const outBuf = Buffer.alloc(outW * outH * 3);
const halfW = outW / 2;
const halfH = outH / 2;
for (let py = 0; py < outH; py++) {
for (let px = 0; px < outW; px++) {
const camX = (px - halfW + 0.5);
const camY = -(py - halfH + 0.5);
const camZ = focalPx;
const len = Math.sqrt(camX * camX + camY * camY + camZ * camZ);
const nx = camX / len;
const ny = camY / len;
const nz = camZ / len;
const [wx, wy, wz] = rotate(nx, ny, nz);
const lon = Math.atan2(wx, wz);
const lat = Math.asin(Math.max(-1, Math.min(1, wy)));
const u = ((lon / Math.PI + 1) / 2) * pW;
const v = (0.5 - lat / Math.PI) * pH;
const [r, g, b] = sampleBilinear(panoBuf, pW, pH, u, v);
const outIdx = (py * outW + px) * 3;
outBuf[outIdx] = r;
outBuf[outIdx + 1] = g;
outBuf[outIdx + 2] = b;
}
}
return outBuf;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
// Compute focal length from hfov, or use provided focal
let focalPx;
if (opts.focal !== undefined) {
focalPx = opts.focal;
} else {
const hfovRad = opts.hfov * Math.PI / 180;
focalPx = (opts.width / 2) / Math.tan(hfovRad / 2);
}
// Build list of headings to render
const headings = [];
if (opts.all) {
for (let h = 0; h < 360; h += opts.step) {
headings.push(h);
}
} else {
headings.push(opts.heading);
}
const fovLabel = opts.focal !== undefined
? `f${Math.round(opts.focal)}`
: `fov${Math.round(opts.hfov)}`;
const hfovDeg = 2 * Math.atan((opts.width / 2) / focalPx) * 180 / Math.PI;
console.log('\nPano → Pinhole Renderer');
console.log(` Input : ${opts.input}`);
console.log(` Mode : ${opts.all ? `all headings (${headings.length} views, step ${opts.step}°)` : `heading ${opts.heading}°`}`);
console.log(` Pitch : ${opts.pitch}°`);
if (opts.roll !== 0) console.log(` Roll : ${opts.roll}°`);
console.log(` HFOV : ${hfovDeg.toFixed(1)}° (focal ≈ ${focalPx.toFixed(1)} px)`);
console.log(` Output : ${opts.width}x${opts.height}`);
// Load panorama once
console.log('\nLoading panorama...');
const inputPath = resolve(opts.input);
const panoImage = sharp(inputPath);
const meta = await panoImage.metadata();
const pW = meta.width;
const pH = meta.height;
console.log(` Panorama : ${pW}x${pH}`);
const panoBuf = await panoImage
.removeAlpha()
.raw()
.toBuffer();
const outdir = resolve(PROJECT_ROOT, opts.outdir);
mkdirSync(outdir, { recursive: true });
const inputBase = basename(opts.input, extname(opts.input));
const outW = opts.width;
const outH = opts.height;
console.log(`\nRendering ${headings.length} view(s)...`);
const t0 = performance.now();
for (const heading of headings) {
const t1 = performance.now();
const outBuf = renderView(panoBuf, pW, pH, heading, opts.pitch, opts.roll, focalPx, outW, outH);
const renderMs = (performance.now() - t1).toFixed(0);
const compassLabel = COMPASS_NAMES[heading] || `H${heading}`;
const outName = `pinhole_${inputBase}_h${heading}_p${opts.pitch}_${fovLabel}_${outW}x${outH}_${compassLabel}.jpg`;
const outPath = join(outdir, outName);
await sharp(outBuf, { raw: { width: outW, height: outH, channels: 3 } })
.jpeg({ quality: 92 })
.toFile(outPath);
console.log(` ${compassLabel.padEnd(4)} (${String(heading).padStart(3)}°): ${outName} (${renderMs}ms)`);
}
const totalElapsed = ((performance.now() - t0) / 1000).toFixed(2);
console.log(`\n Total: ${headings.length} images in ${totalElapsed}s`);
// Report camera parameters
const vfov = 2 * Math.atan((outH / 2) / focalPx) * 180 / Math.PI;
const diagPx = Math.sqrt(outW * outW + outH * outH);
const dfov = 2 * Math.atan((diagPx / 2) / focalPx) * 180 / Math.PI;
console.log(` Horizontal FOV : ${hfovDeg.toFixed(1)}°`);
console.log(` Vertical FOV : ${vfov.toFixed(1)}°`);
console.log(` Diagonal FOV : ${dfov.toFixed(1)}°`);
console.log(` Focal length : ${focalPx.toFixed(1)} px`);
}
main().catch((err) => {
console.error('\nError:', err.message);
process.exit(1);
});
+355
View File
@@ -0,0 +1,355 @@
#!/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);
});
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* streetview-headings.mjs
*
* Fetches Street View static images at 8 compass headings for a given lat/lon.
* Uses the Street View Static API (not the Tiles API).
*
* Usage:
* node tools/streetview-headings.mjs --lat 30.268066 --lon -97.742813
* node tools/streetview-headings.mjs --lat 30.268066 --lon -97.742813 --fov 90 --pitch -10
* node tools/streetview-headings.mjs --lat 30.266476 --lon -97.73719 --neighbors
*
* Options:
* --lat Latitude (required)
* --lon Longitude (required)
* --fov Field of view in degrees (default: 90, range: 10-120)
* --pitch Camera pitch in degrees (default: 0, range: -90 to 90)
* --size Image size WxH (default: 640x640)
* --outdir Output directory (default: output/)
* --key Google Maps API key (default: reads from .env)
* --neighbors Also fetch 8 images from each first-order neighbor location
*/
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
function parseArgs() {
const args = process.argv.slice(2);
const opts = { fov: 90, pitch: 0, size: '640x640', outdir: 'output', neighbors: false };
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 '--fov': opts.fov = parseInt(args[++i], 10); break;
case '--pitch': opts.pitch = parseInt(args[++i], 10); break;
case '--size': opts.size = args[++i]; break;
case '--outdir': opts.outdir = args[++i]; break;
case '--key': opts.key = args[++i]; break;
case '--step': opts.step = parseInt(args[++i], 10); break;
case '--neighbors': opts.neighbors = true; break;
case '--help':
console.log('Usage: node tools/streetview-headings.mjs --lat <lat> --lon <lon> [--fov 90] [--pitch 0] [--size 640x640] [--neighbors]');
process.exit(0);
}
}
if (opts.lat === undefined || opts.lon === undefined) {
console.error('Error: --lat and --lon are required.');
process.exit(1);
}
return opts;
}
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);
}
// ---------------------------------------------------------------------------
// Fetch first-order neighbor locations via Google's internal metadata endpoint
// ---------------------------------------------------------------------------
async function getNeighborLocations(lat, lon) {
const url = `https://maps.googleapis.com/maps/api/js/GeoPhotoService.SingleImageSearch?pb=!1m5!1sapiv3!5sUS!11m2!1m1!1b0!2m4!1m2!3d${lat}!4d${lon}!2d50!3m18!2m2!1sen!2sUS!9m1!1e2!11m12!1m3!1e2!2b1!3e2!1m3!1e3!2b1!3e2!1m3!1e10!2b1!3e2!4m6!1e1!1e2!1e3!1e4!1e8!1e6&callback=_`;
const res = await fetch(url);
if (!res.ok) return [];
const text = await res.text();
// Strip JSONP wrapper: "_ && _( ... )"
const jsonStr = text.replace(/^[^(]*\(\s*/, '').replace(/\s*\)\s*;?\s*$/, '');
let data;
try {
data = JSON.parse(jsonStr);
} catch {
console.log(' Warning: could not parse neighbor metadata');
return [];
}
// Navigate the nested response to extract linked panorama locations.
// Structure: data[1][5][0][3][0] is array of linked panos.
// Each link: link[2][0] = [null, null, lat, lng], link[3][2][0][0] = street name
const neighbors = [];
try {
const links = data[1][5][0][3][0];
// Origin coords from link[0]
const originLat = links[0][2][0][2];
const originLon = links[0][2][0][3];
// Skip index 0 (that's the origin pano itself)
for (let i = 1; i < links.length; i++) {
const link = links[i];
const coordBlock = link[2];
if (!coordBlock || !coordBlock[0]) continue;
const nlat = coordBlock[0][2];
const nlon = coordBlock[0][3];
if (nlat === undefined || nlon === undefined) continue;
// Haversine distance — only keep first-order neighbors (within ~15m)
const dlat = (nlat - originLat) * Math.PI / 180;
const dlon = (nlon - originLon) * Math.PI / 180;
const a = Math.sin(dlat / 2) ** 2
+ Math.cos(originLat * Math.PI / 180) * Math.cos(nlat * Math.PI / 180)
* Math.sin(dlon / 2) ** 2;
const dist = 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
if (dist > 15) continue;
// Extract street name if available
let street = null;
try { street = link[3][2][0][0]; } catch { /* no street name */ }
neighbors.push({ lat: nlat, lon: nlon, street, dist });
}
} catch {
console.log(' Warning: could not extract neighbor links from metadata');
}
return neighbors;
}
// ---------------------------------------------------------------------------
// Fetch 8 heading images for a single location
// ---------------------------------------------------------------------------
async function fetchHeadingsForLocation(lat, lon, label, directions, opts, apiKey, outdir) {
console.log(`\n [${label}] ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
for (const dir of directions) {
const url = `https://maps.googleapis.com/maps/api/streetview?location=${lat},${lon}&heading=${dir.heading}&pitch=${opts.pitch}&fov=${opts.fov}&size=${opts.size}&source=outdoor&key=${apiKey}`;
const res = await fetch(url, { headers: { 'Referer': 'http://localhost:4173/' } });
if (!res.ok) {
console.log(` ${dir.name} (${dir.heading}): FAILED ${res.status}`);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
const outPath = join(outdir, `sv_${lat.toFixed(6)}_${lon.toFixed(6)}_${dir.heading.toString().padStart(3, '0')}_${dir.name}.jpg`);
writeFileSync(outPath, buf);
console.log(` ${dir.name} (${dir.heading}): ${(buf.length / 1024).toFixed(1)} KB`);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const apiKey = loadApiKey(opts.key);
const outdir = resolve(PROJECT_ROOT, opts.outdir);
mkdirSync(outdir, { recursive: true });
// Generate headings at the specified step size (default 45°)
const step = opts.step || 45;
const directions = [];
const compassNames = {
0: 'N', 15: 'NNE', 30: 'NNE2', 45: 'NE', 60: 'ENE', 75: 'ENE2',
90: 'E', 105: 'ESE', 120: 'ESE2', 135: 'SE', 150: 'SSE', 165: 'SSE2',
180: 'S', 195: 'SSW', 210: 'SSW2', 225: 'SW', 240: 'WSW', 255: 'WSW2',
270: 'W', 285: 'WNW', 300: 'WNW2', 315: 'NW', 330: 'NNW', 345: 'NNW2',
};
for (let h = 0; h < 360; h += step) {
directions.push({ name: compassNames[h] || `H${h}`, heading: h });
}
console.log(`\nStreet View Headings`);
console.log(` Location: ${opts.lat}, ${opts.lon}`);
console.log(` FOV: ${opts.fov}, Pitch: ${opts.pitch}, Size: ${opts.size}`);
console.log(` Neighbors: ${opts.neighbors ? 'yes' : 'no'}`);
// Fetch origin point
await fetchHeadingsForLocation(opts.lat, opts.lon, 'origin', directions, opts, apiKey, outdir);
// Fetch first-order neighbors
if (opts.neighbors) {
console.log(`\nFetching neighbor locations...`);
const neighbors = await getNeighborLocations(opts.lat, opts.lon);
if (neighbors.length === 0) {
console.log(' No neighbors found.');
} else {
// Deduplicate: the static API snaps to nearest panorama, so nearby coords
// resolve to the same imagery. Use the metadata API to get the actual pano_id
// each coord resolves to, and skip duplicates.
console.log(` Raw neighbors: ${neighbors.length} (deduplicating via metadata...)`);
const seenPanos = new Set();
// Mark origin pano as seen
const originMeta = await fetch(
`https://maps.googleapis.com/maps/api/streetview/metadata?location=${opts.lat},${opts.lon}&source=outdoor&key=${apiKey}`,
{ headers: { 'Referer': 'http://localhost:4173/' } }
);
if (originMeta.ok) {
const oj = await originMeta.json();
if (oj.pano_id) seenPanos.add(oj.pano_id);
}
const uniqueNeighbors = [];
for (const n of neighbors) {
const metaRes = await fetch(
`https://maps.googleapis.com/maps/api/streetview/metadata?location=${n.lat},${n.lon}&source=outdoor&key=${apiKey}`,
{ headers: { 'Referer': 'http://localhost:4173/' } }
);
if (!metaRes.ok) continue;
const meta = await metaRes.json();
if (!meta.pano_id || seenPanos.has(meta.pano_id)) continue;
seenPanos.add(meta.pano_id);
// Use the snapped location from metadata for more accurate coords
uniqueNeighbors.push({
lat: meta.location.lat,
lon: meta.location.lng,
street: n.street,
dist: n.dist,
});
}
console.log(` Unique neighbors: ${uniqueNeighbors.length}`);
for (const n of uniqueNeighbors) {
const streetLabel = n.street ? ` (${n.street})` : '';
console.log(` ${n.lat.toFixed(6)}, ${n.lon.toFixed(6)} ${n.dist.toFixed(1)}m${streetLabel}`);
}
for (let i = 0; i < uniqueNeighbors.length; i++) {
const n = uniqueNeighbors[i];
const label = n.street || `neighbor-${i + 1}`;
await fetchHeadingsForLocation(n.lat, n.lon, label, directions, opts, apiKey, outdir);
}
}
}
console.log('\nDone.');
}
main().catch((err) => {
console.error('\nError:', err.message);
process.exit(1);
});
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* streetview-panorama.mjs
*
* Fetches and stitches a full Street View panorama for any lat/lon
* using the Google Map Tiles API (Street View Tiles endpoint).
*
* Usage:
* node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813
* node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813 --zoom 5
* node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813 --radius 100
*
* Options:
* --lat Latitude (required)
* --lon Longitude (required)
* --zoom Tile zoom level 0-5 (default: 3, full res = 5)
* --radius Search radius in meters for nearest panorama (default: 50)
* --outdir Output directory (default: output/)
* --key Google Maps API key (default: reads GOOGLE_MAPS_API_KEY from .env or env)
*
* Requires: Map Tiles API enabled on the Google Cloud project.
* Dependencies: sharp (npm install sharp)
*/
import { readFileSync, mkdirSync, writeFileSync } 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, '..');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
function parseArgs() {
const args = process.argv.slice(2);
const opts = { zoom: 3, radius: 50, 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 '--radius': opts.radius = 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/streetview-panorama.mjs --lat <lat> --lon <lon> [--zoom 0-5] [--radius m] [--outdir dir] [--key apikey]`);
process.exit(0);
}
}
if (opts.lat === undefined || opts.lon === undefined) {
console.error('Error: --lat and --lon are required.');
process.exit(1);
}
if (opts.zoom < 0 || opts.zoom > 5) {
console.error('Error: --zoom must be between 0 and 5.');
process.exit(1);
}
return opts;
}
// ---------------------------------------------------------------------------
// Load API key from .env or environment
// ---------------------------------------------------------------------------
function loadApiKey(overrideKey) {
if (overrideKey) return overrideKey;
if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY;
// Try to load from .env
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);
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
const TILE_API = 'https://tile.googleapis.com/v1';
const COMMON_HEADERS = { 'Referer': 'http://localhost:4173/' };
async function createSession(apiKey) {
const url = `${TILE_API}/createSession?key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { ...COMMON_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ mapType: 'streetview', language: 'en-US', region: 'US' }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`createSession failed (${res.status}): ${text}`);
}
return res.json();
}
async function findPanoId(apiKey, session, lat, lng, radius) {
const url = `${TILE_API}/streetview/panoIds?session=${session}&key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { ...COMMON_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: [{ lat, lng }], radius }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`panoIds search failed (${res.status}): ${text}`);
}
const data = await res.json();
const panoId = data.panoIds?.[0];
if (!panoId) {
throw new Error(`No Street View panorama found within ${radius}m of ${lat}, ${lng}`);
}
return panoId;
}
async function getMetadata(apiKey, session, panoId) {
const url = `${TILE_API}/streetview/metadata?session=${session}&key=${apiKey}&panoId=${panoId}`;
const res = await fetch(url, { headers: COMMON_HEADERS });
if (!res.ok) {
const text = await res.text();
throw new Error(`metadata failed (${res.status}): ${text}`);
}
return res.json();
}
async function fetchTile(apiKey, session, panoId, z, x, y) {
const url = `${TILE_API}/streetview/tiles/${z}/${x}/${y}?session=${session}&key=${apiKey}&panoId=${panoId}`;
const res = await fetch(url, { headers: COMMON_HEADERS });
if (!res.ok) {
// Some edge tiles may be empty — return null
if (res.status === 404 || res.status === 204) return null;
const text = await res.text();
throw new Error(`tile ${z}/${x}/${y} failed (${res.status}): ${text}`);
}
return Buffer.from(await res.arrayBuffer());
}
// ---------------------------------------------------------------------------
// Panorama stitching
// ---------------------------------------------------------------------------
async function stitchPanorama(apiKey, session, panoId, metadata, zoom) {
const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata;
// Street View tile grids follow a fixed power-of-2 scheme:
// zoom 0: 1×1 (360° FOV, entire panorama in one tile)
// zoom 1: 2×1
// zoom 2: 4×2
// zoom 3: 8×4
// zoom 4: 16×8
// zoom 5: 32×16 (11.25° FOV, but actual cols/rows may be fewer
// since tiles past the image edge return 404)
//
// The grid dimensions are independent of imageWidth/imageHeight.
// Lower-resolution panoramas (e.g. contributor panos) simply 404
// at higher zoom levels where there aren't enough pixels.
//
// We use the fixed grid, then let fetchTile silently return null for
// any tiles that 404 (edge padding). The stitched canvas size is
// grid × tileSize, and real image content fills the upper-left portion.
const tilesX = Math.pow(2, zoom); // cols: 1,2,4,8,16,32
const tilesY = Math.max(1, Math.pow(2, zoom - 1)); // rows: 1,1,2,4, 8,16
// The panorama image is an equirectangular projection: width = 360°,
// height = 180°. The tile grid is always 2:1 aspect (except zoom 0
// which is 1×1). The image content fills the grid, scaled to fit.
const canvasWidth = tilesX * tileWidth;
const canvasHeight = tilesY * tileHeight;
// For the output, we use full canvas dimensions. The content fills
// the grid (the server scales the panorama to fit). Any minor
// padding at edges is harmless for equirectangular math.
const panoWidth = canvasWidth;
const panoHeight = canvasHeight;
console.log(`Panorama at zoom ${zoom}: grid ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${panoWidth}x${panoHeight}px)`);
// Fetch all tiles with concurrency limit
const CONCURRENCY = 6;
const tiles = [];
const queue = [];
for (let y = 0; y < tilesY; y++) {
for (let x = 0; x < tilesX; x++) {
queue.push({ x, y });
}
}
let completed = 0;
const total = queue.length;
async function worker() {
while (queue.length > 0) {
const { x, y } = queue.shift();
const buf = await fetchTile(apiKey, session, panoId, zoom, x, y);
tiles.push({ x, y, buf });
completed++;
if (completed % 10 === 0 || completed === total) {
process.stdout.write(` Fetched ${completed}/${total} tiles\r`);
}
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, () => worker());
await Promise.all(workers);
console.log(` Fetched ${total}/${total} tiles`);
// Stitch with sharp
console.log(`Stitching ${panoWidth}x${panoHeight} panorama...`);
const composites = [];
for (const { x, y, buf } of tiles) {
if (!buf) continue;
composites.push({
input: buf,
left: x * tileWidth,
top: y * tileHeight,
});
}
const panorama = sharp({
create: {
width: panoWidth,
height: panoHeight,
channels: 3,
background: { r: 0, g: 0, b: 0 },
},
}).composite(composites);
const outBuf = await panorama.jpeg({ quality: 92 }).toBuffer();
return { buffer: outBuf, width: panoWidth, height: panoHeight };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const opts = parseArgs();
const apiKey = loadApiKey(opts.key);
console.log(`\nStreet View Panorama Fetcher`);
console.log(` Location: ${opts.lat}, ${opts.lon}`);
console.log(` Zoom: ${opts.zoom} (0=tiny, 5=full res 13312x6656)`);
console.log(` Radius: ${opts.radius}m\n`);
// Step 1: Create session
console.log('Creating session...');
const sessionData = await createSession(apiKey);
const session = sessionData.session;
console.log(` Session created (tile size: ${sessionData.tileWidth}x${sessionData.tileHeight})\n`);
// Step 2: Find nearest Google panorama (skip contributor panos)
// Strategy: probe the requested point + N/S/E/W offsets, collect all
// Google panos found (including their linked neighbors), pick closest.
console.log(`Searching for Google panorama near ${opts.lat}, ${opts.lon}...`);
const isGoogle = (m) => m.copyright?.includes('Google');
const offsetM = 0.00027; // ~30m in degrees
const distTo = (m) => {
const dy = (m.lat - opts.lat) * 111320;
const dx = (m.lng - opts.lon) * 111320 * Math.cos(opts.lat * Math.PI / 180);
return Math.sqrt(dy * dy + dx * dx);
};
const probePoints = [
{ lat: opts.lat, lng: opts.lon },
{ lat: opts.lat + offsetM, lng: opts.lon },
{ lat: opts.lat - offsetM, lng: opts.lon },
{ lat: opts.lat, lng: opts.lon + offsetM },
{ lat: opts.lat, lng: opts.lon - offsetM },
{ lat: opts.lat + offsetM * 2, lng: opts.lon },
{ lat: opts.lat - offsetM * 2, lng: opts.lon },
{ lat: opts.lat, lng: opts.lon + offsetM * 2 },
{ lat: opts.lat, lng: opts.lon - offsetM * 2 },
];
const seen = new Map(); // panoId → metadata
const googlePanos = [];
let fallback = null;
for (const pt of probePoints) {
let pId;
try {
pId = await findPanoId(apiKey, session, pt.lat, pt.lng, opts.radius);
} catch { continue; }
if (seen.has(pId)) continue;
const candidate = await getMetadata(apiKey, session, pId);
seen.set(pId, candidate);
console.log(` Probe ${pt.lat.toFixed(5)},${pt.lng.toFixed(5)} → ${pId.slice(0, 20)}… ${distTo(candidate).toFixed(0)}m (${candidate.copyright})`);
if (isGoogle(candidate)) {
googlePanos.push(candidate);
// Also check immediate links for closer Google panos
for (const link of (candidate.links || [])) {
if (seen.has(link.panoId)) continue;
const linked = await getMetadata(apiKey, session, link.panoId);
seen.set(link.panoId, linked);
if (isGoogle(linked)) {
console.log(` Link ${link.panoId.slice(0, 20)}… ${distTo(linked).toFixed(0)}m (${linked.copyright})`);
googlePanos.push(linked);
}
}
break; // found Google cluster, no need to probe further
}
if (!fallback) fallback = candidate;
}
// Pick the Google pano closest to the requested point
let meta;
if (googlePanos.length > 0) {
googlePanos.sort((a, b) => distTo(a) - distTo(b));
meta = googlePanos[0];
console.log(` Selected: ${meta.panoId.slice(0, 20)}… (${distTo(meta).toFixed(0)}m away)`);
} else {
meta = fallback;
console.log(` No Google pano found, using best available.`);
}
console.log(`\nMetadata:`);
console.log(` Panorama ID: ${meta.panoId}`);
console.log(` Location: ${meta.lat}, ${meta.lng}`);
console.log(` Image size: ${meta.imageWidth}x${meta.imageHeight}`);
console.log(` Heading: ${meta.heading} degrees`);
console.log(` Tilt: ${meta.tilt} degrees`);
console.log(` Date: ${meta.date || 'unknown'}`);
console.log(` Copyright: ${meta.copyright}`);
if (meta.links?.length) {
console.log(` Links to ${meta.links.length} adjacent panorama(s):`);
for (const link of meta.links) {
console.log(` - ${link.text || 'unnamed'} heading=${link.heading.toFixed(1)} panoId=${link.panoId}`);
}
}
console.log();
// Distance from requested location to actual panorama
const dlat = (meta.lat - opts.lat) * 111320;
const dlng = (meta.lng - opts.lon) * 111320 * Math.cos(opts.lat * Math.PI / 180);
const distM = Math.sqrt(dlat * dlat + dlng * dlng);
console.log(` Distance from requested point: ${distM.toFixed(1)}m\n`);
// Step 4: Fetch and stitch panorama
const { buffer: panoBuffer, width: panoWidth, height: panoHeight } = await stitchPanorama(
apiKey, session, meta.panoId, meta, opts.zoom
);
// Step 5: Save outputs
const outdir = resolve(PROJECT_ROOT, opts.outdir);
mkdirSync(outdir, { recursive: true });
const panoPath = join(outdir, `panorama_${opts.lat}_${opts.lon}.jpg`);
writeFileSync(panoPath, panoBuffer);
console.log(`\nSaved: ${panoPath} (${(panoBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
console.log(` Panorama: ${panoWidth}x${panoHeight}, heading=${meta.heading.toFixed(1)}, tilt=${meta.tilt.toFixed(1)}`);
}
main().catch((err) => {
console.error('\nError:', err.message);
process.exit(1);
});