mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Release God's Eye View as open source
This commit is contained in:
1 parent
a5aaea7e16
commit
880a672b5e
433 files changed
+219893
-23
No files matched your search
+182
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+377
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user