/* DepthWizard v4 viewer — dual-pane DSM inspector + 3D flythrough.
*
* Loads a prediction directory written by `infer/predict.py`:
*
* ndsm_m.npy float32 (H, W), metres above ground <- preferred, exact
* ndsm16.png 16-bit fallback + the affine in meta.json
* rgb.png the optical image that produced it
* meta.json the contract, the scene metadata, the diagnostics
* gt_ndsm_m.npy (optional) reference surface -> live metrics + error layer
*
* The .npy path exists because a 16-bit PNG drawn into a 2-D canvas is
* down-converted to 8 bits by the browser, which would quantise a 0-60 m range
* into 0.23 m steps before anything is rendered. Metres are the product here,
* so the viewer reads the float array directly and only falls back to the PNG.
*
* No build step, no bundler, no network: three.js r128 is vendored beside this
* file, so `index.html` opened from a USB stick works exactly like the served
* copy. That is the "standalone deployment" half of the rubric.
*
* Three ways in, all landing on the same `adopt()`:
*
* file picker -> loadFromFiles() a prediction directory off disk
* ?result= -> loadFromUrl() a served prediction directory
* window.DW_EMBED -> loadFromEmbed() a single prediction inlined into the
* page, which is how the Gradio Space
* hands its result to the iframe
*
* The embedded path cannot ship a .npy through an `srcdoc` iframe without
* base64-ing tens of megabytes, so it carries heights as a 16-bit value split
* across the red and green channels of an 8-bit PNG: `(R*256+G)/65535` over a
* stated [min, max] range. A 2-D canvas read returns those two channels
* byte-exactly, so the decode is lossless to ~1e-5 of the range (sub-millimetre
* on any real scene) rather than the 8-bit 0.23 m steps a plain grey ramp gives.
*/
'use strict';
// ---------------------------------------------------------------------------
// small utilities
// ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const fmt = (v, n = 2) => (v === null || v === undefined || !isFinite(v) ? '—' : v.toFixed(n));
/** Minimal NumPy .npy v1/v2 reader — enough for a 2-D float or int array. */
function parseNpy(buffer) {
const b = new Uint8Array(buffer);
const magic = String.fromCharCode(...b.slice(1, 6));
if (magic !== 'NUMPY') throw new Error('not a .npy file');
const major = b[6];
const hlenBytes = major === 1 ? 2 : 4;
const dv = new DataView(buffer);
const hlen = major === 1 ? dv.getUint16(8, true) : dv.getUint32(8, true);
const start = 8 + hlenBytes;
const header = new TextDecoder().decode(b.slice(start, start + hlen));
const descr = /'descr':\s*'([^']+)'/.exec(header)[1];
const fortran = /'fortran_order':\s*(True|False)/.exec(header)[1] === 'True';
const shape = /'shape':\s*\(([^)]*)\)/.exec(header)[1]
.split(',').map((s) => s.trim()).filter(Boolean).map(Number);
if (fortran) throw new Error('fortran-order .npy is not supported');
const off = start + hlen;
const n = shape.reduce((a, c) => a * c, 1);
const kind = descr.replace(/^[<>|=]/, '');
let data;
if (kind === 'f4') data = new Float32Array(buffer, off, n);
else if (kind === 'f8') data = Float32Array.from(new Float64Array(buffer, off, n));
else if (kind === 'u1') data = Float32Array.from(new Uint8Array(buffer, off, n));
else if (kind === 'i2') data = Float32Array.from(new Int16Array(buffer, off, n));
else if (kind === 'i4') data = Float32Array.from(new Int32Array(buffer, off, n));
else throw new Error('unsupported dtype ' + descr);
return { data, shape };
}
/* Turbo colormap, 9-stop piecewise linear. Perceptually ordered and, unlike
* jet, monotone in luminance — so a printed report still reads correctly. */
const TURBO = [
[48, 18, 59], [70, 107, 227], [40, 187, 226], [61, 231, 154],
[163, 244, 78], [231, 215, 53], [253, 152, 39], [232, 79, 13], [122, 4, 3],
];
function turbo(t) {
t = clamp(t, 0, 1) * (TURBO.length - 1);
const i = Math.min(TURBO.length - 2, Math.floor(t));
const f = t - i, a = TURBO[i], b = TURBO[i + 1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
/* Diverging blue-white-red, for signed error. */
function diverging(t) {
const x = clamp(t, -1, 1);
return x < 0
? [255 * (1 + x * 0.85), 255 * (1 + x * 0.55), 255]
: [255, 255 * (1 - x * 0.55), 255 * (1 - x * 0.85)];
}
/** Draw a vertical colour scale into `wrap`'s canvas, or hide it for layers
* that carry no scalar scale (optical RGB). `lo`/`hi` are in metres for the
* turbo ramp, or a signed +/-limit in metres for the diverging error ramp. */
function drawColorbar(wrapId, mode, lo, hi, errLim) {
const wrap = $(wrapId);
if (!wrap) return;
const cv = wrap.querySelector('canvas');
if (mode !== 'height' && mode !== 'reference' && mode !== 'error') {
wrap.style.display = 'none';
return;
}
if (mode === 'error') hi = errLim;
wrap.style.display = 'flex';
const w = cv.width, h = cv.height;
const g = cv.getContext('2d');
const img = g.createImageData(w, h);
for (let y = 0; y < h; y++) {
const t = 1 - y / (h - 1);
const [r, gg, b] = mode === 'error' ? diverging(t * 2 - 1) : turbo(t);
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
img.data[i] = r; img.data[i + 1] = gg; img.data[i + 2] = b; img.data[i + 3] = 255;
}
}
g.putImageData(img, 0, 0);
let labelHi = wrap.querySelector('.cb-hi'), labelLo = wrap.querySelector('.cb-lo');
if (!labelHi) {
labelHi = document.createElement('div'); labelHi.className = 'cb-hi';
labelLo = document.createElement('div'); labelLo.className = 'cb-lo';
labelLo.style.marginTop = '2px';
wrap.insertBefore(labelHi, cv);
wrap.appendChild(labelLo);
}
const unit = mode === 'error' ? ' m err' : ' m';
labelHi.textContent = fmt(hi, 1) + unit;
labelLo.textContent = fmt(mode === 'error' ? -hi : lo, 1) + unit;
}
// ---------------------------------------------------------------------------
// the loaded scene
// ---------------------------------------------------------------------------
const S = {
W: 0, H: 0, gsd: 0.5,
height: null, // Float32Array, metres
gt: null, // Float32Array or null
rgb: null, // HTMLCanvasElement
meta: null,
hMin: 0, hMax: 1,
probeA: null, probeB: null,
};
function decodeHeightFromPng(img, meta) {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(img, 0, 0);
const px = g.getImageData(0, 0, c.width, c.height).data;
const lo = meta.height_min_m ?? 0, hi = meta.height_max_m ?? 1;
const out = new Float32Array(c.width * c.height);
for (let i = 0; i < out.length; i++) out[i] = lo + (px[i * 4] / 255) * (hi - lo);
warn('height came from an 8-bit canvas read of ndsm16.png — precision is ~' +
((hi - lo) / 255).toFixed(2) + ' m. Load ndsm_m.npy for exact metres.');
return { data: out, shape: [c.height, c.width] };
}
/** Decode heights packed as (R*256+G)/65535 over [lo, hi] — see the header. */
function decodeHeightRG16(img, lo, hi) {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
const g = c.getContext('2d', { willReadFrequently: true });
g.drawImage(img, 0, 0);
const px = g.getImageData(0, 0, c.width, c.height).data;
const span = hi - lo;
const out = new Float32Array(c.width * c.height);
for (let i = 0; i < out.length; i++) {
out[i] = lo + ((px[i * 4] * 256 + px[i * 4 + 1]) / 65535) * span;
}
return { data: out, shape: [c.height, c.width] };
}
function statsOf(a) {
let lo = Infinity, hi = -Infinity, sum = 0, n = 0;
for (let i = 0; i < a.length; i++) {
const v = a[i];
if (!isFinite(v)) continue;
if (v < lo) lo = v; if (v > hi) hi = v; sum += v; n++;
}
return { min: lo, max: hi, mean: n ? sum / n : 0, n };
}
function compareToReference() {
if (!S.gt) return null;
let se = 0, ae = 0, sd = 0, n = 0;
let sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0;
for (let i = 0; i < S.height.length; i++) {
const p = S.height[i], t = S.gt[i];
if (!isFinite(p) || !isFinite(t)) continue;
const d = p - t;
se += d * d; ae += Math.abs(d); sd += d; n++;
sx += p; sy += t; sxx += p * p; syy += t * t; sxy += p * t;
}
if (!n) return null;
const cov = sxy / n - (sx / n) * (sy / n);
const vx = sxx / n - (sx / n) ** 2, vy = syy / n - (sy / n) ** 2;
return {
rmse: Math.sqrt(se / n), mae: ae / n, bias: sd / n,
r: cov / (Math.sqrt(vx * vy) + 1e-12), n,
};
}
// ---------------------------------------------------------------------------
// texture layers (2-D pane and the 3-D drape share these)
// ---------------------------------------------------------------------------
function layerCanvas(mode) {
if (mode === 'rgb' && S.rgb) return S.rgb;
const c = document.createElement('canvas');
c.width = S.W; c.height = S.H;
const g = c.getContext('2d');
const img = g.createImageData(S.W, S.H);
const d = img.data;
const span = Math.max(S.hMax - S.hMin, 1e-3);
if (mode === 'hillshade') {
const sh = hillshade(S.height, S.W, S.H, S.gsd, +$('sunAz').value, +$('sunEl').value);
for (let i = 0; i < sh.length; i++) {
const v = sh[i]; d[i * 4] = d[i * 4 + 1] = d[i * 4 + 2] = v; d[i * 4 + 3] = 255;
}
} else if (mode === 'error' && S.gt) {
let lim = 0;
for (let i = 0; i < S.height.length; i++) lim = Math.max(lim, Math.abs(S.height[i] - S.gt[i]));
lim = Math.max(lim * 0.6, 1);
for (let i = 0; i < S.height.length; i++) {
const [r, gg, b] = diverging((S.height[i] - S.gt[i]) / lim);
d[i * 4] = r; d[i * 4 + 1] = gg; d[i * 4 + 2] = b; d[i * 4 + 3] = 255;
}
} else {
const src = mode === 'reference' && S.gt ? S.gt : S.height;
for (let i = 0; i < src.length; i++) {
const [r, gg, b] = turbo((src[i] - S.hMin) / span);
d[i * 4] = r; d[i * 4 + 1] = gg; d[i * 4 + 2] = b; d[i * 4 + 3] = 255;
}
}
g.putImageData(img, 0, 0);
return c;
}
/** Horn hillshade — the honest test of whether a DSM is shaped like terrain. */
function hillshade(h, W, H, gsd, azDeg, elDeg) {
const out = new Uint8ClampedArray(W * H);
const az = ((360 - azDeg + 90) * Math.PI) / 180, el = (elDeg * Math.PI) / 180;
const sinEl = Math.sin(el), cosEl = Math.cos(el);
for (let y = 0; y < H; y++) {
const y0 = Math.max(0, y - 1) * W, y1 = Math.min(H - 1, y + 1) * W;
for (let x = 0; x < W; x++) {
const x0 = Math.max(0, x - 1), x1 = Math.min(W - 1, x + 1);
const dx = (h[y * W + x1] - h[y * W + x0]) / (gsd * (x1 - x0 || 1));
const dy = (h[y1 + x] - h[y0 + x]) / (gsd * 2 || 1);
const slope = Math.atan(Math.hypot(dx, dy));
const aspect = Math.atan2(-dx, dy);
out[y * W + x] = 255 * clamp(
sinEl * Math.cos(slope) + cosEl * Math.sin(slope) * Math.cos(az - aspect), 0, 1);
}
}
return out;
}
// ---------------------------------------------------------------------------
// three.js scene
// ---------------------------------------------------------------------------
let renderer, scene, camera, orbit, fpControls, mesh, sun, hemi, probeGroup, pathLine;
let mode3d = 'orbit';
let flying = false, flyT = 0, flyCurve = null;
const keys = Object.create(null);
let lastFrame = performance.now(), fps = 0;
function initThree() {
const host = $('pane3d');
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
host.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0e1116);
camera = new THREE.PerspectiveCamera(58, 1, 0.5, 40000);
hemi = new THREE.HemisphereLight(0xbfd4ff, 0x2a2a28, 0.65);
scene.add(hemi);
sun = new THREE.DirectionalLight(0xfff3e0, 1.15);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
scene.add(sun, sun.target);
orbit = new THREE.OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.dampingFactor = 0.08;
orbit.maxPolarAngle = Math.PI * 0.495;
fpControls = new THREE.PointerLockControls(camera, renderer.domElement);
scene.add(fpControls.getObject ? fpControls.getObject() : new THREE.Object3D());
fpControls.addEventListener('unlock', () => { if (mode3d === 'fp') setMode('orbit'); });
probeGroup = new THREE.Group();
scene.add(probeGroup);
addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'KeyF' && !e.repeat) setMode(mode3d === 'fp' ? 'orbit' : 'fp');
if (e.code === 'KeyR' && !e.repeat) resetCamera();
});
addEventListener('keyup', (e) => { keys[e.code] = false; });
renderer.domElement.addEventListener('pointerdown', onPick);
addEventListener('resize', resize);
resize();
animate();
}
function resize() {
if (!renderer) return;
const host = $('pane3d');
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
draw2d();
}
function decimation() {
const budget = +$('quality').value;
let step = 1;
while (Math.ceil(S.H / step) * Math.ceil(S.W / step) > budget) step++;
return step;
}
function build3dColorbar() {
const layer = $('layer').value;
let errLim = 1;
if (layer === 'error' && S.gt) {
for (let i = 0; i < S.height.length; i++) errLim = Math.max(errLim, Math.abs(S.height[i] - S.gt[i]));
errLim = Math.max(errLim * 0.6, 1);
}
drawColorbar('colorbar3d', layer, S.hMin, S.hMax, errLim);
}
function buildMesh() {
// a ~1M-vertex grid blocks the main thread for a noticeable moment; give
// the spinner a frame to paint before the synchronous work below starts
const spinner = $('meshSpinner');
if (spinner) spinner.classList.add('show');
setTimeout(() => _buildMeshNow(), 0);
}
function _buildMeshNow() {
if (mesh) {
mesh.geometry.dispose();
mesh.material.map?.dispose();
mesh.material.dispose();
scene.remove(mesh);
}
const step = decimation();
const gw = Math.floor((S.W - 1) / step) + 1;
const gh = Math.floor((S.H - 1) / step) + 1;
const spacing = S.gsd * step;
// PlaneGeometry is built in XY then rotated to XZ, so (col, row) maps to
// (x, z) with x growing east and z growing south — the same convention
// viz/mesh.py writes, and the same one the UV mapping below assumes.
const geo = new THREE.PlaneGeometry((gw - 1) * spacing, (gh - 1) * spacing, gw - 1, gh - 1);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let r = 0; r < gh; r++) {
for (let c = 0; c < gw; c++) {
const sr = Math.min(S.H - 1, r * step), sc = Math.min(S.W - 1, c * step);
pos.setY(r * gw + c, S.height[sr * S.W + sc]);
}
}
pos.needsUpdate = true;
geo.computeVertexNormals();
const tex = new THREE.CanvasTexture(layerCanvas($('layer').value));
tex.encoding = THREE.sRGBEncoding;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
const mat = new THREE.MeshStandardMaterial({
map: tex, roughness: 0.95, metalness: 0.0,
wireframe: $('wire').checked, side: THREE.DoubleSide,
});
mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
mesh.castShadow = true;
mesh.position.set((gw - 1) * spacing / 2, 0, (gh - 1) * spacing / 2);
scene.add(mesh);
applyExaggeration();
updateSun();
buildFlyPath();
resetCamera();
build3dColorbar();
$('vertCount').textContent = (gw * gh).toLocaleString() + ' verts (1:' + step + ')';
$('meshSpinner')?.classList.remove('show');
}
function applyExaggeration() {
const v = +$('vex').value;
$('vexLabel').textContent = v.toFixed(1) + '×';
if (mesh) mesh.scale.y = v;
redrawProbe();
}
function sceneExtent() {
return { x: S.W * S.gsd, z: S.H * S.gsd };
}
function resetCamera() {
const e = sceneExtent();
const d = Math.max(e.x, e.z);
orbit.target.set(e.x / 2, 0, e.z / 2);
camera.position.set(e.x / 2, d * 0.55, e.z / 2 + d * 0.75);
camera.up.set(0, 1, 0);
orbit.update();
}
function updateSun() {
const az = (+$('sunAz').value * Math.PI) / 180;
const el = (+$('sunEl').value * Math.PI) / 180;
const e = sceneExtent();
const d = Math.max(e.x, e.z) * 1.5;
sun.position.set(
e.x / 2 + d * Math.cos(el) * Math.sin(az),
d * Math.sin(el) + 10,
e.z / 2 + d * Math.cos(el) * Math.cos(az));
sun.target.position.set(e.x / 2, 0, e.z / 2);
sun.target.updateMatrixWorld();
const c = sun.shadow.camera;
c.left = -d; c.right = d; c.top = d; c.bottom = -d; c.near = 1; c.far = d * 4;
c.updateProjectionMatrix();
sun.castShadow = $('shadows').checked;
}
/** A closed cinematic orbit for the demo video, elevated over the scene. */
function buildFlyPath() {
if (pathLine) { scene.remove(pathLine); pathLine.geometry.dispose(); }
const e = sceneExtent();
const cx = e.x / 2, cz = e.z / 2;
const rad = Math.max(e.x, e.z) * 0.42;
const pts = [];
for (let i = 0; i < 24; i++) {
const a = (i / 24) * Math.PI * 2;
const wob = 1 + 0.18 * Math.sin(a * 3);
pts.push(new THREE.Vector3(
cx + rad * wob * Math.cos(a),
(S.hMax * 1.6 + 60) * (0.75 + 0.35 * Math.sin(a * 2)),
cz + rad * wob * Math.sin(a)));
}
flyCurve = new THREE.CatmullRomCurve3(pts, true, 'catmullrom', 0.5);
const geo = new THREE.BufferGeometry().setFromPoints(flyCurve.getPoints(300));
pathLine = new THREE.Line(geo, new THREE.LineBasicMaterial({
color: 0x2d6cdf, transparent: true, opacity: 0.35 }));
pathLine.visible = false;
scene.add(pathLine);
}
function setMode(m) {
mode3d = m;
flying = m === 'fly';
if (pathLine) pathLine.visible = flying;
orbit.enabled = m === 'orbit';
if (m === 'fp') { try { fpControls.lock(); } catch (e) { /* needs a gesture */ } }
else if (fpControls.isLocked) fpControls.unlock();
for (const b of document.querySelectorAll('[data-mode]')) {
const on = b.dataset.mode === m;
b.classList.toggle('on', on);
b.setAttribute('aria-pressed', String(on));
}
$('hint').innerHTML = {
orbit: 'click the scene to focus it, then: drag = orbit · scroll = zoom · ' +
'click = probe · F = first person · R = reset
' +
'touch: one finger = orbit · pinch = zoom · two-finger drag = pan',
fp: 'WASD = move · Shift = sprint · Space/C = up/down · mouse = look · Esc = exit',
fly: 'cinematic drone orbit — press Orbit to take control',
}[m];
}
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const dt = Math.min(0.1, (now - lastFrame) / 1000);
lastFrame = now;
fps = fps * 0.9 + (1 / Math.max(dt, 1e-4)) * 0.1;
if (mode3d === 'orbit') orbit.update();
else if (mode3d === 'fp' && fpControls.isLocked) {
const base = Math.max(sceneExtent().x, sceneExtent().z) * 0.12;
const sp = base * (keys.ShiftLeft || keys.ShiftRight ? 3 : 1) * dt;
if (keys.KeyW) fpControls.moveForward(sp);
if (keys.KeyS) fpControls.moveForward(-sp);
if (keys.KeyA) fpControls.moveRight(-sp);
if (keys.KeyD) fpControls.moveRight(sp);
if (keys.Space) camera.position.y += sp;
if (keys.KeyC) camera.position.y -= sp;
} else if (flying && flyCurve) {
flyT = (flyT + dt * 0.02) % 1;
camera.position.copy(flyCurve.getPointAt(flyT));
const e = sceneExtent();
camera.lookAt(e.x / 2, S.hMax * 0.3, e.z / 2);
}
if (renderer && mesh) renderer.render(scene, camera);
$('fps').textContent = fps.toFixed(0) + ' fps';
}
// ---------------------------------------------------------------------------
// probing: height read-out, two-point slope, elevation profile
// ---------------------------------------------------------------------------
const ray = new THREE.Raycaster();
function onPick(ev) {
if (!mesh || mode3d === 'fp') return;
const r = renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((ev.clientX - r.left) / r.width) * 2 - 1,
-((ev.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ndc, camera);
const hit = ray.intersectObject(mesh, false)[0];
if (!hit) return;
const p = hit.point;
const col = clamp(Math.round(p.x / S.gsd), 0, S.W - 1);
const row = clamp(Math.round(p.z / S.gsd), 0, S.H - 1);
addProbe({ row, col });
}
function sampleAt(row, col) {
const i = clamp(row, 0, S.H - 1) * S.W + clamp(col, 0, S.W - 1);
return { h: S.height[i], gt: S.gt ? S.gt[i] : null };
}
function addProbe(p) {
if (!S.probeA || (S.probeA && S.probeB)) { S.probeA = p; S.probeB = null; }
else S.probeB = p;
redrawProbe();
reportProbe();
}
function redrawProbe() {
while (probeGroup.children.length) {
const c = probeGroup.children.pop();
c.geometry?.dispose(); c.material?.dispose();
probeGroup.remove(c);
}
const vex = mesh ? mesh.scale.y : 1;
const marker = (p, color) => {
const s = Math.max(sceneExtent().x, sceneExtent().z) * 0.006;
const m = new THREE.Mesh(
new THREE.SphereGeometry(s, 12, 10),
new THREE.MeshBasicMaterial({ color }));
m.position.set(p.col * S.gsd, sampleAt(p.row, p.col).h * vex, p.row * S.gsd);
probeGroup.add(m);
};
if (S.probeA) marker(S.probeA, 0x2d6cdf);
if (S.probeB) {
marker(S.probeB, 0xe5793a);
const a = new THREE.Vector3(S.probeA.col * S.gsd,
sampleAt(S.probeA.row, S.probeA.col).h * vex, S.probeA.row * S.gsd);
const b = new THREE.Vector3(S.probeB.col * S.gsd,
sampleAt(S.probeB.row, S.probeB.col).h * vex, S.probeB.row * S.gsd);
probeGroup.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineBasicMaterial({ color: 0xffffff })));
}
draw2d();
}
function lonLatAt(row, col) {
const t = S.meta?.scene?.transform; // [a, b, c, d, e, f] affine, if written
if (!Array.isArray(t) || t.length < 6) return null;
return { x: t[0] * col + t[1] * row + t[2], y: t[3] * col + t[4] * row + t[5] };
}
function reportProbe() {
const out = [];
if (S.probeA) {
const a = sampleAt(S.probeA.row, S.probeA.col);
out.push(`A px (${S.probeA.col}, ${S.probeA.row}) · ${fmt(a.h)} m` +
(a.gt !== null ? ` · reference ${fmt(a.gt)} m · err ${fmt(a.h - a.gt)} m` : ''));
const ll = lonLatAt(S.probeA.row, S.probeA.col);
if (ll) out.push(` map (${ll.x.toFixed(2)}, ${ll.y.toFixed(2)})`);
}
if (S.probeA && S.probeB) {
const a = sampleAt(S.probeA.row, S.probeA.col), b = sampleAt(S.probeB.row, S.probeB.col);
const dx = (S.probeB.col - S.probeA.col) * S.gsd;
const dz = (S.probeB.row - S.probeA.row) * S.gsd;
const run = Math.hypot(dx, dz), rise = b.h - a.h;
out.push(`B px (${S.probeB.col}, ${S.probeB.row}) · ${fmt(b.h)} m`);
out.push(`ground distance ${fmt(run, 1)} m · Δh ${fmt(rise)} m`);
out.push(`slope ${fmt((rise / Math.max(run, 1e-6)) * 100, 1)} % ` +
`(${fmt((Math.atan2(rise, Math.max(run, 1e-6)) * 180) / Math.PI, 1)}°)`);
drawProfile();
} else {
$('profile').getContext('2d').clearRect(0, 0, 999, 999);
}
$('probe').innerHTML = out.join('
') ||
'click the terrain to probe a height; click again for slope and a profile';
}
function drawProfile() {
const cv = $('profile'), g = cv.getContext('2d');
const w = cv.width = cv.clientWidth * devicePixelRatio;
const h = cv.height = cv.clientHeight * devicePixelRatio;
g.clearRect(0, 0, w, h);
const n = 220, pred = [], ref = [];
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
const r = Math.round(S.probeA.row + (S.probeB.row - S.probeA.row) * t);
const c = Math.round(S.probeA.col + (S.probeB.col - S.probeA.col) * t);
const s = sampleAt(r, c);
pred.push(s.h);
if (s.gt !== null) ref.push(s.gt);
}
const all = ref.length ? pred.concat(ref) : pred;
const lo = Math.min(...all), hi = Math.max(...all);
const span = Math.max(hi - lo, 1);
const pad = 4 * devicePixelRatio;
const line = (arr, color, width) => {
g.strokeStyle = color; g.lineWidth = width * devicePixelRatio;
g.beginPath();
arr.forEach((v, i) => {
const x = pad + (i / (n - 1)) * (w - 2 * pad);
const y = h - pad - ((v - lo) / span) * (h - 2 * pad);
i ? g.lineTo(x, y) : g.moveTo(x, y);
});
g.stroke();
};
if (ref.length) line(ref, '#8a949e', 1.2);
line(pred, '#2d6cdf', 1.6);
g.fillStyle = '#8a949e';
g.font = `${10 * devicePixelRatio}px system-ui`;
g.textAlign = 'left';
g.fillText(`${hi.toFixed(1)} m`, pad, 11 * devicePixelRatio);
g.fillText(`${lo.toFixed(1)} m`, pad, h - 3 * devicePixelRatio);
const groundDist = Math.hypot(
(S.probeB.col - S.probeA.col) * S.gsd, (S.probeB.row - S.probeA.row) * S.gsd);
g.textAlign = 'right';
g.fillText(`A → B · ${groundDist.toFixed(1)} m ground`, w - pad, 11 * devicePixelRatio);
g.textAlign = 'left';
}
// ---------------------------------------------------------------------------
// 2-D pane
// ---------------------------------------------------------------------------
function draw2d() {
const cv = $('map'), g = cv.getContext('2d');
const host = $('pane2d');
const w = cv.width = Math.max(1, host.clientWidth * devicePixelRatio);
const h = cv.height = Math.max(1, (host.clientHeight - 34) * devicePixelRatio);
g.clearRect(0, 0, w, h);
if (!S.height) return;
const layer2d = $('layer2d').value;
const src = layerCanvas(layer2d);
const sc = Math.min(w / S.W, h / S.H);
const dw = S.W * sc, dh = S.H * sc;
const ox = (w - dw) / 2, oy = (h - dh) / 2;
g.imageSmoothingEnabled = sc > 1.5;
g.drawImage(src, ox, oy, dw, dh);
if ($('contours').checked) drawContours(g, ox, oy, sc);
drawScaleBar(g, w, h, sc);
const errLim = (() => {
if (layer2d !== 'error' || !S.gt) return 1;
let lim = 0;
for (let i = 0; i < S.height.length; i++) lim = Math.max(lim, Math.abs(S.height[i] - S.gt[i]));
return Math.max(lim * 0.6, 1);
})();
drawColorbar('colorbar2d', layer2d, S.hMin, S.hMax, errLim);
// everything below is an overlay on the raster, so clip it to the raster —
// the camera can sit outside the scene and its indicator should not scribble
// across the letterbox
g.save();
g.beginPath();
g.rect(ox, oy, dw, dh);
g.clip();
// camera footprint, so the two panes are visibly the same place
if (camera) {
const px = (camera.position.x / S.gsd) * sc + ox;
const py = (camera.position.z / S.gsd) * sc + oy;
const tx = (orbit.target.x / S.gsd) * sc + ox;
const ty = (orbit.target.z / S.gsd) * sc + oy;
g.strokeStyle = '#ffd166'; g.lineWidth = 1.4 * devicePixelRatio;
g.beginPath(); g.moveTo(px, py); g.lineTo(tx, ty); g.stroke();
g.fillStyle = '#ffd166';
g.beginPath(); g.arc(px, py, 4 * devicePixelRatio, 0, 7); g.fill();
}
const dot = (p, col) => {
if (!p) return;
g.fillStyle = col;
g.beginPath();
g.arc(p.col * sc + ox, p.row * sc + oy, 4 * devicePixelRatio, 0, 7);
g.fill();
};
if (S.probeA && S.probeB) {
g.strokeStyle = '#fff'; g.lineWidth = 1.5 * devicePixelRatio;
g.beginPath();
g.moveTo(S.probeA.col * sc + ox, S.probeA.row * sc + oy);
g.lineTo(S.probeB.col * sc + ox, S.probeB.row * sc + oy);
g.stroke();
}
dot(S.probeA, '#2d6cdf');
dot(S.probeB, '#e5793a');
g.restore();
cv._map = { ox, oy, sc };
}
/** A ground-distance scale bar sized to a round number of metres. */
function drawScaleBar(g, canvasW, canvasH, sc) {
const cv = $('scalebar');
if (!cv || !S.gsd) return;
const host = $('pane2d');
cv.width = host.clientWidth * devicePixelRatio;
cv.height = 34 * devicePixelRatio;
const gg = cv.getContext('2d');
gg.clearRect(0, 0, cv.width, cv.height);
const pxPerMetre = sc / S.gsd;
const maxBarPx = canvasW * 0.28;
const niceSteps = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000, 2000, 5000];
let metres = niceSteps[0];
for (const step of niceSteps) {
if (step * pxPerMetre <= maxBarPx) metres = step; else break;
}
const barPx = metres * pxPerMetre;
const x0 = 10 * devicePixelRatio, y = cv.height - 12 * devicePixelRatio;
gg.strokeStyle = '#e8ecf1'; gg.lineWidth = 2 * devicePixelRatio;
gg.beginPath();
gg.moveTo(x0, y); gg.lineTo(x0 + barPx, y);
gg.moveTo(x0, y - 5 * devicePixelRatio); gg.lineTo(x0, y + 5 * devicePixelRatio);
gg.moveTo(x0 + barPx, y - 5 * devicePixelRatio); gg.lineTo(x0 + barPx, y + 5 * devicePixelRatio);
gg.stroke();
gg.fillStyle = '#e8ecf1';
gg.font = `${11 * devicePixelRatio}px system-ui`;
gg.textAlign = 'center';
gg.fillText(`${metres} m`, x0 + barPx / 2, y - 8 * devicePixelRatio);
gg.textAlign = 'left';
}
function drawContours(g, ox, oy, sc) {
const step = Math.max(1, Math.round((S.hMax - S.hMin) / 12));
g.strokeStyle = 'rgba(255,255,255,.35)';
g.lineWidth = 1;
// marching-squares-lite: draw a pixel wherever a cell straddles a level
for (let y = 0; y < S.H - 1; y += 1) {
for (let x = 0; x < S.W - 1; x += 1) {
const a = S.height[y * S.W + x], b = S.height[y * S.W + x + 1];
const la = Math.floor(a / step), lb = Math.floor(b / step);
if (la !== lb) g.fillRect(x * sc + ox, y * sc + oy, Math.max(sc, 1), Math.max(sc, 1));
}
}
g.fillStyle = 'rgba(255,255,255,.35)';
}
$('map')?.addEventListener('pointerdown', (ev) => {
const cv = $('map'), m = cv._map;
if (!m || !S.height) return;
const r = cv.getBoundingClientRect();
const x = ((ev.clientX - r.left) * devicePixelRatio - m.ox) / m.sc;
const y = ((ev.clientY - r.top) * devicePixelRatio - m.oy) / m.sc;
if (x < 0 || y < 0 || x >= S.W || y >= S.H) return;
addProbe({ row: Math.round(y), col: Math.round(x) });
});
// ---------------------------------------------------------------------------
// loading
// ---------------------------------------------------------------------------
const _warnSeen = new Set();
const _WARN_MAX = 6;
function warn(msg) {
const el = $('warn');
if (!el || _warnSeen.has(msg)) return;
_warnSeen.add(msg);
el.style.display = 'block';
el.classList.add('show');
const line = document.createElement('div');
line.textContent = msg;
el.appendChild(line);
while (el.children.length > _WARN_MAX) el.removeChild(el.firstChild);
}
function clearWarnings() {
const el = $('warn');
if (!el) return;
el.innerHTML = '';
el.style.display = 'none';
el.classList.remove('show');
_warnSeen.clear();
}
async function loadFromFiles(fileList) {
const files = {};
for (const f of fileList) files[f.name.toLowerCase()] = f;
const read = (name, how) => {
const f = files[name];
if (!f) return null;
return how === 'text' ? f.text() : f.arrayBuffer();
};
const metaTxt = await read('meta.json', 'text');
const meta = metaTxt ? JSON.parse(metaTxt) : {};
let hgt = null;
const npy = await read('ndsm_m.npy', 'buf') || await read('pred_ndsm_m.npy', 'buf');
if (npy) hgt = parseNpy(npy);
else if (files['ndsm16.png'] || files['height16.png']) {
const img = await fileToImage(files['ndsm16.png'] || files['height16.png']);
hgt = decodeHeightFromPng(img, meta);
}
if (!hgt) { warn('no height array found (need ndsm_m.npy or ndsm16.png)'); return; }
let gt = null;
const gtBuf = await read('gt_ndsm_m.npy', 'buf');
if (gtBuf) gt = parseNpy(gtBuf).data;
let rgb = null;
const rgbFile = files['rgb.png'] || files['rgb.jpg'] || files['texture.png'];
if (rgbFile) rgb = imageToCanvas(await fileToImage(rgbFile));
adopt({ hgt, gt, rgb, meta });
}
function srcToImage(src) {
return new Promise((res, rej) => {
const img = new Image();
img.onload = () => res(img);
img.onerror = () => rej(new Error('image failed to decode'));
img.src = src;
});
}
function fileToImage(file) {
return srcToImage(URL.createObjectURL(file));
}
function imageToCanvas(img) {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
return c;
}
async function loadFromUrl(base) {
base = base.replace(/\/+$/, '') + '/';
const meta = await fetch(base + 'meta.json').then((r) => r.json());
const hgt = parseNpy(await fetch(base + 'ndsm_m.npy').then((r) => r.arrayBuffer()));
let gt = null;
try {
gt = parseNpy(await fetch(base + 'gt_ndsm_m.npy').then((r) => {
if (!r.ok) throw new Error('no reference');
return r.arrayBuffer();
})).data;
} catch (e) { /* optional */ }
let rgb = null;
try {
const img = new Image();
img.src = base + 'rgb.png';
await img.decode();
rgb = imageToCanvas(img);
} catch (e) { /* optional */ }
adopt({ hgt, gt, rgb, meta });
}
/** The prediction the host inlined into this page, if there is one.
*
* Two shapes are accepted: a `window.DW_EMBED` object assigned by an inline
* script, or — what the Gradio Space actually sends — a
* `