SingleViewHeigthEstimation / viewer /phase0_viewer.html
akashch1512's picture
First Push
16ce72c
Raw History Blame Contribute Delete
8.21 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DepthWizard — Phase 0 Viewer</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.5 system-ui, sans-serif; background: #0d1013; color: #e6e6e6; overflow: hidden; }
#ui {
position: fixed; top: 12px; left: 12px; z-index: 10; width: 300px;
background: rgba(18,22,26,.92); border: 1px solid #2b3138; border-radius: 8px; padding: 14px;
}
#ui h1 { margin: 0 0 4px; font-size: 14px; }
#ui p.sub { margin: 0 0 12px; color: #8a949e; }
#ui label { display: block; margin: 10px 0 3px; color: #b8c2cc; }
#ui input[type=range] { width: 100%; }
#ui .row { display: flex; gap: 8px; align-items: center; }
#ui button {
margin-top: 12px; width: 100%; padding: 8px; background: #2d6cdf; color: #fff;
border: 0; border-radius: 6px; cursor: pointer; font-weight: 600;
}
#ui button.secondary { background: #333b45; }
#meta { margin-top: 10px; padding-top: 10px; border-top: 1px solid #2b3138; color: #8a949e; white-space: pre-wrap; }
#hint { position: fixed; bottom: 12px; left: 12px; color: #6b7580; z-index: 10; }
canvas { display: block; }
</style>
</head>
<body>
<div id="ui">
<h1>DepthWizard · Phase 0</h1>
<p class="sub">Predicted nDSM → 3D mesh, RGB draped.</p>
<label>Load export from <code>outputs/v1/viewer_sample/</code></label>
<input type="file" id="files" multiple accept=".png,.json" />
<label>Vertical exaggeration <span id="vexLabel">2.0×</span></label>
<input type="range" id="vex" min="0.2" max="8" step="0.1" value="2" />
<label>Wireframe</label>
<div class="row"><input type="checkbox" id="wire" /> <span>show mesh grid</span></div>
<button id="fp">Toggle first-person (WASD + drag)</button>
<button id="reset" class="secondary">Reset camera</button>
<div id="meta">Pick rgb.png, height16.png and meta.json…</div>
</div>
<div id="hint">drag = orbit · scroll = zoom · first-person: WASD + mouse-drag</div>
<script src="vendor/three.min.js"></script>
<script src="vendor/OrbitControls.js"></script>
<script src="vendor/PointerLockControls.js"></script>
<script>
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1013);
scene.fog = new THREE.Fog(0x0d1013, 400, 1600);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 6000);
const HOME = new THREE.Vector3(0, 260, 340);
camera.position.copy(HOME);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
if ('outputEncoding' in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const orbit = new THREE.OrbitControls(camera, renderer.domElement);
orbit.target.set(0, 0, 0);
orbit.maxPolarAngle = Math.PI * 0.495;
const fpc = new THREE.PointerLockControls(camera, renderer.domElement);
const keys = {};
addEventListener('keydown', e => keys[e.code] = true);
addEventListener('keyup', e => keys[e.code] = false);
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x202020, 1.1));
const sun = new THREE.DirectionalLight(0xffffff, 1.6);
sun.position.set(-300, 500, 200);
scene.add(sun);
let mesh = null;
let meta = { height_min_m: 0, height_max_m: 1, gsd_m: 0.33, size_px: 512 };
const loader = new THREE.TextureLoader();
const store = {};
document.getElementById('files').addEventListener('change', async e => {
for (const f of e.target.files) {
const url = URL.createObjectURL(f);
if (f.name.endsWith('.json')) meta = JSON.parse(await f.text());
else if (f.name.toLowerCase().includes('rgb')) store.rgb = url;
else if (f.name.toLowerCase().includes('height')) store.height = url;
}
if (store.rgb && store.height) buildMesh();
});
function buildMesh() {
loader.load(store.height, hTex => {
// read the 16-bit-ish PNG back to a Float32 displacement grid
const img = hTex.image;
// 1 world unit == 1 pixel of the prediction grid, which is what the
// metres -> pixel conversion below assumes, so take the extents straight
// from the height PNG. meta.size_px is only a sanity check (and its [w, h]
// vs [h, w] order differs between v2 and v3 exports).
const worldX = img.width, worldZ = img.height;
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, img.width, img.height).data;
const span = (meta.height_max_m - meta.height_min_m) || 1;
const hmin = isFinite(meta.height_min_m) ? meta.height_min_m : 0;
const gsd = (typeof meta.gsd_m === 'number' && meta.gsd_m > 0) ? meta.gsd_m : 0.33;
// Decimate the grid so the index count stays under the WebGL
// max-vert-ids-per-draw limit (~30M indices => ~2200 segments/side).
const MAX_SEG = 2000;
const segX = Math.min(img.width - 1, MAX_SEG);
const segY = Math.min(img.height - 1, MAX_SEG);
const geo = new THREE.PlaneGeometry(worldX, worldZ, segX, segY);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
const vertsX = segX + 1;
for (let i = 0; i < pos.count; i++) {
const gx = i % vertsX, gy = Math.floor(i / vertsX);
const px = Math.min(img.width - 1, Math.round(gx / segX * (img.width - 1)));
// PlaneGeometry row gy carries uv.v = 1 - gy/segY, and textures upload with
// flipY, so gy == 0 drapes image row 0. Sample the height grid in the same
// top-down order or the relief ends up mirrored against the RGB.
const py = Math.min(img.height - 1, Math.round(gy / segY * (img.height - 1)));
const g = data[(py * img.width + px) * 4] / 255; // 8-bit read is enough for a spike
const h_m = hmin + g * span;
pos.setY(i, h_m / gsd); // metres -> pixel units, matches XZ scale
}
geo.computeVertexNormals();
loader.load(store.rgb, rgbTex => {
if ('colorSpace' in rgbTex) rgbTex.colorSpace = 'srgb';
else rgbTex.encoding = THREE.sRGBEncoding;
const mat = new THREE.MeshStandardMaterial({ map: rgbTex, roughness: 0.95, metalness: 0.0 });
if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); }
mesh = new THREE.Mesh(geo, mat);
mesh.userData.baseScaleY = 1;
scene.add(mesh);
applyVex();
document.getElementById('meta').textContent =
`tile: ${meta.stem || '?'}\n` +
`height range: ${meta.height_min_m?.toFixed(1)} – ${meta.height_max_m?.toFixed(1)} m\n` +
`GSD: ${meta.gsd_m} m/px · ${img.width}×${img.height}px`;
});
});
}
const vex = document.getElementById('vex');
function applyVex() {
if (mesh) mesh.scale.y = parseFloat(vex.value);
document.getElementById('vexLabel').textContent = parseFloat(vex.value).toFixed(1) + '×';
}
vex.addEventListener('input', applyVex);
document.getElementById('wire').addEventListener('change', e => {
if (mesh) mesh.material.wireframe = e.target.checked;
});
document.getElementById('reset').addEventListener('click', () => {
camera.position.copy(HOME); orbit.target.set(0, 0, 0); orbit.update();
});
document.getElementById('fp').addEventListener('click', () => {
if (fpc.isLocked) fpc.unlock(); else fpc.lock();
});
fpc.addEventListener('lock', () => orbit.enabled = false);
fpc.addEventListener('unlock', () => orbit.enabled = true);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
if (fpc.isLocked) {
const v = 160 * dt;
if (keys.KeyW) fpc.moveForward(v);
if (keys.KeyS) fpc.moveForward(-v);
if (keys.KeyA) fpc.moveRight(-v);
if (keys.KeyD) fpc.moveRight(v);
if (keys.Space) camera.position.y += v;
if (keys.ShiftLeft) camera.position.y -= v;
} else {
orbit.update();
}
renderer.render(scene, camera);
}
animate();
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>