gods-eye-view/tools/pano-pinhole.mjs

374 lines
13 KiB
JavaScript

#!/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);
});