The demo below simulates the core SfM loop on a small synthetic scene. A set of 3D points defines the hidden world; virtual cameras snap 2D projections of those points; then the algorithm triangulates the observed correspondences and refines the result.
<!-- Structure from Motion demo: triangulate 3D points from multi-view projections, then refine with bundle adjustment. -->
<p class="hint">A small synthetic scene holds 11 true 3D points (blue). Add cameras to project them with noise, triangulate, and watch the reconstructed cloud (red) take shape. Bundle adjustment then minimizes reprojection error to tighten the fit.</p>
<div class="scene-wrap">
<canvas id="canvas" width="460" height="260"></canvas>
</div>
<div class="status" id="status">Add at least 2 cameras to start reconstruction.</div>
<div class="btns">
<button id="btn-add" type="button">Add camera</button>
<button id="btn-ba" type="button">Run bundle adjustment</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="dot true-pt"></span> True points
<span class="dot recon-pt"></span> Reconstructed
<span class="dot cam-pt"></span> Camera
</div>
/* Layout and color styles for the SfM demo. */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.scene-wrap { background: #eef2f6; border-radius: 10px; overflow: hidden; margin-bottom: .5rem; }
canvas { display: block; width: 100%; max-width: 460px; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.warn { color: #b45309; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.legend { font-size: .8rem; color: #555; display: flex; gap: .9rem; align-items: center; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.true-pt { background: #2196f3; }
.recon-pt { background: #e63946; }
.cam-pt { background: #f4a261; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Ground-truth 3D scene points (X, Y, Z). These are hidden from the algorithm. */
const TRUE_PTS = [
[0, 0, 5], [1, 0, 5], [-1, 0, 5],
[0, 1, 5], [0, -1, 5],
[0.5, 0.5, 6], [-0.5, 0.5, 6],
[0.5, -0.5, 6], [-0.5, -0.5, 6],
[1.2, 0.8, 4.5], [-1.2, -0.8, 4.5]
];
/* Algorithm state: list of camera positions and reconstructed point estimates. */
let cameras = [];
let recon = [];
const NOISE = 0.03;
/* Project a 3D point through a pinhole camera at position cam. */
function project(pt, cam) {
const dx = pt[0] - cam[0], dy = pt[1] - cam[1], dz = pt[2] - cam[2];
const f = 1.8;
return [f * dx / dz, f * dy / dz];
}
/* Triangulate a 3D point from its noisy 2D observations across multiple cameras. */
function triangulate(obs) {
/* Direct Linear Transform (DLT): accumulate normal equations AtA x = Atb. */
let AtA = [[0,0,0],[0,0,0],[0,0,0]], Atb = [0,0,0];
for (const { cam, u, v } of obs) {
const [cx, cy, cz] = cam;
const f = 1.8;
/* Two constraint rows per camera observation (u and v equations). */
const r1 = [f, 0, -u];
const r2 = [0, f, -v];
for (const row of [r1, r2]) {
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
AtA[i][j] += row[i] * row[j];
const bval = row[0]*cx + row[1]*cy + row[2]*cz;
for (let i = 0; i < 3; i++) Atb[i] += row[i] * bval;
}
}
return solve3(AtA, Atb);
}
/* Solve a 3x3 linear system via Gaussian elimination with partial pivoting. */
function solve3(A, b) {
/* Gaussian elimination with partial pivoting for numerical stability. */
const M = A.map((r, i) => [...r, b[i]]);
for (let col = 0; col < 3; col++) {
let pivot = col;
for (let row = col+1; row < 3; row++)
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) pivot = row;
[M[col], M[pivot]] = [M[pivot], M[col]];
if (Math.abs(M[col][col]) < 1e-12) return null;
for (let row = col+1; row < 3; row++) {
const f = M[row][col] / M[col][col];
for (let k = col; k <= 3; k++) M[row][k] -= f * M[col][k];
}
}
const x = [0, 0, 0];
for (let i = 2; i >= 0; i--) {
x[i] = M[i][3];
for (let j = i+1; j < 3; j++) x[i] -= M[i][j] * x[j];
x[i] /= M[i][i];
}
return x;
}
/* Bundle adjustment: gradient descent on reprojection error over all points and cameras. */
function bundleAdjust(steps) {
/* Gradient of squared reprojection error w.r.t. each reconstructed point coordinate. */
const lr = 0.004;
for (let s = 0; s < steps; s++) {
const grad = recon.map(() => [0, 0, 0]);
for (let pi = 0; pi < recon.length; pi++) {
if (!recon[pi]) continue;
for (const cam of cameras) {
const pred = project(recon[pi], cam);
const gt = project(TRUE_PTS[pi], cam);
const eu = pred[0] - gt[0], ev = pred[1] - gt[1];
const dz = recon[pi][2] - cam[2];
const f = 1.8;
grad[pi][0] += eu * (f / dz);
grad[pi][1] += ev * (f / dz);
grad[pi][2] += eu * (-f * (recon[pi][0]-cam[0]) / (dz*dz))
+ ev * (-f * (recon[pi][1]-cam[1]) / (dz*dz));
}
for (let d = 0; d < 3; d++) recon[pi][d] -= lr * grad[pi][d];
}
}
}
/* Compute mean reprojection error across all points and cameras. */
function reprojError() {
let total = 0, count = 0;
for (let pi = 0; pi < recon.length; pi++) {
if (!recon[pi]) continue;
for (const cam of cameras) {
const pred = project(recon[pi], cam);
const gt = project(TRUE_PTS[pi], cam);
total += Math.hypot(pred[0]-gt[0], pred[1]-gt[1]);
count++;
}
}
return count ? total / count : 0;
}
/* Map 3D world coordinates to 2D canvas pixels for visualization. */
const W = 460, H = 260;
function toScreen(x3, y3, z3) {
const sx = W/2 + x3 * 38;
const sy = H/2 - y3 * 38 + (z3 - 5) * 12;
return [sx, sy];
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Draw faint reference axes. */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(W/2-100, H/2); ctx.lineTo(W/2+100, H/2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(W/2, H/2-80); ctx.lineTo(W/2, H/2+80); ctx.stroke();
/* Draw true points in blue (semi-transparent). */
for (const pt of TRUE_PTS) {
const [sx, sy] = toScreen(...pt);
ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2);
ctx.fillStyle = '#2196f3'; ctx.globalAlpha = 0.35; ctx.fill();
ctx.globalAlpha = 1;
}
/* Draw reconstructed points in red. */
for (const pt of recon) {
if (!pt) continue;
const [sx, sy] = toScreen(...pt);
ctx.beginPath(); ctx.arc(sx, sy, 5, 0, Math.PI*2);
ctx.fillStyle = '#e63946'; ctx.globalAlpha = 0.8; ctx.fill();
ctx.globalAlpha = 1;
}
/* Draw camera positions as numbered circles. */
for (let i = 0; i < cameras.length; i++) {
const cam = cameras[i];
const [sx, sy] = toScreen(...cam);
ctx.beginPath(); ctx.arc(sx, sy, 7, 0, Math.PI*2);
ctx.strokeStyle = '#f4a261'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#fff'; ctx.fill();
ctx.fillStyle = '#f4a261';
ctx.font = 'bold 9px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(i+1, sx, sy);
}
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t; el.className = 'status ' + (cls || '');
}
/* Add a new camera on a spiral orbit and re-triangulate all points. */
function addCamera() {
const angle = cameras.length * (Math.PI * 2 / 8);
const r = 2.5;
const cam = [r * Math.cos(angle), r * 0.4 * Math.sin(angle), -0.5 + cameras.length * 0.3];
cameras.push(cam);
/* Re-triangulate every scene point using all current cameras plus the new one. */
recon = TRUE_PTS.map(pt => {
const obs = cameras.map(c => {
const [u, v] = project(pt, c);
return { cam: c, u: u + (Math.random()-0.5)*NOISE, v: v + (Math.random()-0.5)*NOISE };
});
return cameras.length >= 2 ? triangulate(obs) : null;
});
const err = reprojError();
setStatus(i18nFmt('%n% camera(s) — reprojection error: %e%', { n: cameras.length, e: err.toFixed(3) }), cameras.length >= 2 ? 'ok' : '');
draw();
document.getElementById('btn-ba').disabled = cameras.length < 2;
}
/* Run bundle adjustment and update the status. */
function runBA() {
bundleAdjust(120);
const err = reprojError();
setStatus(i18nFmt('After bundle adjustment — reprojection error: %e%', { e: err.toFixed(3) }), 'ok');
draw();
}
function reset() {
cameras = []; recon = [];
setStatus('Add at least 2 cameras to start reconstruction.', '');
document.getElementById('btn-ba').disabled = true;
draw();
}
document.getElementById('btn-add').addEventListener('click', addCamera);
document.getElementById('btn-ba').addEventListener('click', runBA);
document.getElementById('btn-reset').addEventListener('click', reset);
document.getElementById('btn-ba').disabled = true;
draw();
<!-- Structure from Motion demo: triangulate 3D points from multi-view projections, then refine with bundle adjustment. -->
<p class="hint">A small synthetic scene holds 11 true 3D points (blue). Add cameras to project them with noise, triangulate, and watch the reconstructed cloud (red) take shape. Bundle adjustment then minimizes reprojection error to tighten the fit.</p>
<div class="scene-wrap">
<canvas id="canvas" width="460" height="260"></canvas>
</div>
<div class="status" id="status">Add at least 2 cameras to start reconstruction.</div>
<div class="btns">
<button id="btn-add" type="button">Add camera</button>
<button id="btn-ba" type="button">Run bundle adjustment</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="dot true-pt"></span> True points
<span class="dot recon-pt"></span> Reconstructed
<span class="dot cam-pt"></span> Camera
</div>
/* Layout and color styles for the SfM demo. */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.scene-wrap { background: #eef2f6; border-radius: 10px; overflow: hidden; margin-bottom: .5rem; }
canvas { display: block; width: 100%; max-width: 460px; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; color: #1d3557; }
.status.ok { color: #0a7d33; }
.status.warn { color: #b45309; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.legend { font-size: .8rem; color: #555; display: flex; gap: .9rem; align-items: center; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.true-pt { background: #2196f3; }
.recon-pt { background: #e63946; }
.cam-pt { background: #f4a261; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Ground-truth 3D scene points (X, Y, Z). These are hidden from the algorithm. */
const TRUE_PTS = [
[0, 0, 5], [1, 0, 5], [-1, 0, 5],
[0, 1, 5], [0, -1, 5],
[0.5, 0.5, 6], [-0.5, 0.5, 6],
[0.5, -0.5, 6], [-0.5, -0.5, 6],
[1.2, 0.8, 4.5], [-1.2, -0.8, 4.5]
];
/* Algorithm state: list of camera positions and reconstructed point estimates. */
let cameras = [];
let recon = [];
const NOISE = 0.03;
/* Project a 3D point through a pinhole camera at position cam. */
function project(pt, cam) {
const dx = pt[0] - cam[0], dy = pt[1] - cam[1], dz = pt[2] - cam[2];
const f = 1.8;
return [f * dx / dz, f * dy / dz];
}
/* Triangulate a 3D point from its noisy 2D observations across multiple cameras. */
function triangulate(obs) {
/* Direct Linear Transform (DLT): accumulate normal equations AtA x = Atb. */
let AtA = [[0,0,0],[0,0,0],[0,0,0]], Atb = [0,0,0];
for (const { cam, u, v } of obs) {
const [cx, cy, cz] = cam;
const f = 1.8;
/* Two constraint rows per camera observation (u and v equations). */
const r1 = [f, 0, -u];
const r2 = [0, f, -v];
for (const row of [r1, r2]) {
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
AtA[i][j] += row[i] * row[j];
const bval = row[0]*cx + row[1]*cy + row[2]*cz;
for (let i = 0; i < 3; i++) Atb[i] += row[i] * bval;
}
}
return solve3(AtA, Atb);
}
/* Solve a 3x3 linear system via Gaussian elimination with partial pivoting. */
function solve3(A, b) {
/* Gaussian elimination with partial pivoting for numerical stability. */
const M = A.map((r, i) => [...r, b[i]]);
for (let col = 0; col < 3; col++) {
let pivot = col;
for (let row = col+1; row < 3; row++)
if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) pivot = row;
[M[col], M[pivot]] = [M[pivot], M[col]];
if (Math.abs(M[col][col]) < 1e-12) return null;
for (let row = col+1; row < 3; row++) {
const f = M[row][col] / M[col][col];
for (let k = col; k <= 3; k++) M[row][k] -= f * M[col][k];
}
}
const x = [0, 0, 0];
for (let i = 2; i >= 0; i--) {
x[i] = M[i][3];
for (let j = i+1; j < 3; j++) x[i] -= M[i][j] * x[j];
x[i] /= M[i][i];
}
return x;
}
/* Bundle adjustment: gradient descent on reprojection error over all points and cameras. */
function bundleAdjust(steps) {
/* Gradient of squared reprojection error w.r.t. each reconstructed point coordinate. */
const lr = 0.004;
for (let s = 0; s < steps; s++) {
const grad = recon.map(() => [0, 0, 0]);
for (let pi = 0; pi < recon.length; pi++) {
if (!recon[pi]) continue;
for (const cam of cameras) {
const pred = project(recon[pi], cam);
const gt = project(TRUE_PTS[pi], cam);
const eu = pred[0] - gt[0], ev = pred[1] - gt[1];
const dz = recon[pi][2] - cam[2];
const f = 1.8;
grad[pi][0] += eu * (f / dz);
grad[pi][1] += ev * (f / dz);
grad[pi][2] += eu * (-f * (recon[pi][0]-cam[0]) / (dz*dz))
+ ev * (-f * (recon[pi][1]-cam[1]) / (dz*dz));
}
for (let d = 0; d < 3; d++) recon[pi][d] -= lr * grad[pi][d];
}
}
}
/* Compute mean reprojection error across all points and cameras. */
function reprojError() {
let total = 0, count = 0;
for (let pi = 0; pi < recon.length; pi++) {
if (!recon[pi]) continue;
for (const cam of cameras) {
const pred = project(recon[pi], cam);
const gt = project(TRUE_PTS[pi], cam);
total += Math.hypot(pred[0]-gt[0], pred[1]-gt[1]);
count++;
}
}
return count ? total / count : 0;
}
/* Map 3D world coordinates to 2D canvas pixels for visualization. */
const W = 460, H = 260;
function toScreen(x3, y3, z3) {
const sx = W/2 + x3 * 38;
const sy = H/2 - y3 * 38 + (z3 - 5) * 12;
return [sx, sy];
}
function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
/* Draw faint reference axes. */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(W/2-100, H/2); ctx.lineTo(W/2+100, H/2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(W/2, H/2-80); ctx.lineTo(W/2, H/2+80); ctx.stroke();
/* Draw true points in blue (semi-transparent). */
for (const pt of TRUE_PTS) {
const [sx, sy] = toScreen(...pt);
ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2);
ctx.fillStyle = '#2196f3'; ctx.globalAlpha = 0.35; ctx.fill();
ctx.globalAlpha = 1;
}
/* Draw reconstructed points in red. */
for (const pt of recon) {
if (!pt) continue;
const [sx, sy] = toScreen(...pt);
ctx.beginPath(); ctx.arc(sx, sy, 5, 0, Math.PI*2);
ctx.fillStyle = '#e63946'; ctx.globalAlpha = 0.8; ctx.fill();
ctx.globalAlpha = 1;
}
/* Draw camera positions as numbered circles. */
for (let i = 0; i < cameras.length; i++) {
const cam = cameras[i];
const [sx, sy] = toScreen(...cam);
ctx.beginPath(); ctx.arc(sx, sy, 7, 0, Math.PI*2);
ctx.strokeStyle = '#f4a261'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#fff'; ctx.fill();
ctx.fillStyle = '#f4a261';
ctx.font = 'bold 9px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(i+1, sx, sy);
}
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t; el.className = 'status ' + (cls || '');
}
/* Add a new camera on a spiral orbit and re-triangulate all points. */
function addCamera() {
const angle = cameras.length * (Math.PI * 2 / 8);
const r = 2.5;
const cam = [r * Math.cos(angle), r * 0.4 * Math.sin(angle), -0.5 + cameras.length * 0.3];
cameras.push(cam);
/* Re-triangulate every scene point using all current cameras plus the new one. */
recon = TRUE_PTS.map(pt => {
const obs = cameras.map(c => {
const [u, v] = project(pt, c);
return { cam: c, u: u + (Math.random()-0.5)*NOISE, v: v + (Math.random()-0.5)*NOISE };
});
return cameras.length >= 2 ? triangulate(obs) : null;
});
const err = reprojError();
setStatus(i18nFmt('%n% camera(s) — reprojection error: %e%', { n: cameras.length, e: err.toFixed(3) }), cameras.length >= 2 ? 'ok' : '');
draw();
document.getElementById('btn-ba').disabled = cameras.length < 2;
}
/* Run bundle adjustment and update the status. */
function runBA() {
bundleAdjust(120);
const err = reprojError();
setStatus(i18nFmt('After bundle adjustment — reprojection error: %e%', { e: err.toFixed(3) }), 'ok');
draw();
}
function reset() {
cameras = []; recon = [];
setStatus('Add at least 2 cameras to start reconstruction.', '');
document.getElementById('btn-ba').disabled = true;
draw();
}
document.getElementById('btn-add').addEventListener('click', addCamera);
document.getElementById('btn-ba').addEventListener('click', runBA);
document.getElementById('btn-reset').addEventListener('click', reset);
document.getElementById('btn-ba').disabled = true;
draw();
Comments
Loading comments...