Below is a simulated room with walls (shown in the preview on the right). A robot starts at the center and sweeps its laser sensor in all directions. Each beam travels until it hits a wall or reaches the max range; the cell it hits gets its occupancy probability pushed up, and all the cells along the beam get their probability pushed down (they are free space).
<!-- Occupancy-grid mapping demo: true room on the left, probabilistic map on the right -->
<div class="toolbar">
<button id="btn-scan" type="button">Scan</button>
<button id="btn-auto" type="button">Auto</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="scan-count" id="scan-count"></span>
</div>
<div class="panels">
<div class="panel">
<div class="panel-label">True room</div>
<canvas id="truth-canvas" width="200" height="200"></canvas>
</div>
<div class="panel">
<div class="panel-label">Robot's map</div>
<canvas id="map-canvas" width="200" height="200"></canvas>
</div>
</div>
<div class="status" id="status">Press Scan to fire a sweep of beams.</div>
/* Styles for the two-panel occupancy-grid mapping demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.toolbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin-bottom: .6rem; }
button { font: 600 13px system-ui, sans-serif; padding: .38rem .75rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #e63946; border-color: #c92f3c; }
.scan-count { font-size: .85rem; color: #555; margin-left: .3rem; }
.panels { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { display: flex; flex-direction: column; align-items: center; gap: .25rem; }
.panel-label { font-size: .8rem; font-weight: 600; color: #555; letter-spacing: .03em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; image-rendering: pixelated; display: block; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Grid dimensions — each cell is one square in the occupancy grid
const COLS = 20, ROWS = 20;
const CELL = 10; // pixels per cell on canvas
// Log-odds update constants derived from the sensor model
const L_OCC = Math.log(0.85 / 0.15); // added to a cell when the beam hits it (occupied evidence)
const L_FREE = Math.log(0.15 / 0.85); // added to a cell the beam passes through (free evidence)
const L_CLAMP = 6; // clamp log-odds so probabilities stay away from 0 and 1
// Ground-truth room: border walls + one inner pillar
// 1 = wall, 0 = free. The room is a rectangle with an inner pillar.
const ROOM = (() => {
const g = [];
for (let r = 0; r < ROWS; r++) {
const row = [];
for (let c = 0; c < COLS; c++) {
const onEdge = r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1;
const pillar = r >= 7 && r <= 9 && c >= 7 && c <= 9;
row.push(onEdge || pillar ? 1 : 0);
}
g.push(row);
}
return g;
})();
// Log-odds grid initialised to 0 (p=0.5, maximum uncertainty)
let logOdds = Array.from({ length: ROWS }, () => new Float32Array(COLS));
let scanCount = 0;
let autoTimer = null;
const truthCtx = document.getElementById('truth-canvas').getContext('2d');
const mapCtx = document.getElementById('map-canvas').getContext('2d');
const statusEl = document.getElementById('status');
const scanCountEl = document.getElementById('scan-count');
const btnAuto = document.getElementById('btn-auto');
// Render the ground-truth room: dark = wall, light = free
function drawTruth() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
truthCtx.fillStyle = ROOM[r][c] ? '#1d3557' : '#f0f4f8';
truthCtx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// red dot marks the robot position
const rx = Math.floor(COLS / 2) * CELL + CELL / 2;
const ry = Math.floor(ROWS / 2) * CELL + CELL / 2;
truthCtx.beginPath();
truthCtx.arc(rx, ry, 4, 0, Math.PI * 2);
truthCtx.fillStyle = '#e63946';
truthCtx.fill();
}
// Render the probabilistic map: dark = likely occupied, light = likely free
function drawMap() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const l = logOdds[r][c];
const p = 1 - 1 / (1 + Math.exp(l));
// convert probability to grayscale: p=1 (occupied) -> black, p=0 (free) -> white
const gray = Math.round((1 - p) * 255);
mapCtx.fillStyle = `rgb(${gray},${gray},${gray})`;
mapCtx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// robot position on the map
const rx = Math.floor(COLS / 2) * CELL + CELL / 2;
const ry = Math.floor(ROWS / 2) * CELL + CELL / 2;
mapCtx.beginPath();
mapCtx.arc(rx, ry, 4, 0, Math.PI * 2);
mapCtx.fillStyle = '#e63946';
mapCtx.fill();
}
// Ray-casting: march in direction (dr,dc) until hitting a wall or leaving the grid
// Cast a ray from (r0,c0) in direction (dr,dc). Returns cells along the beam.
function castRay(r0, c0, dr, dc) {
const cells = [];
let r = r0, c = c0;
const maxSteps = Math.max(ROWS, COLS) * 2;
for (let i = 0; i < maxSteps; i++) {
r += dr; c += dc;
const ri = Math.round(r), ci = Math.round(c);
if (ri < 0 || ri >= ROWS || ci < 0 || ci >= COLS) break;
cells.push({ ri, ci, hit: ROOM[ri][ci] === 1 });
if (ROOM[ri][ci] === 1) break; // stop ray at first wall hit
}
return cells;
}
// One scan: cast 36 beams from the robot centre and update log-odds
function scan() {
const rr = Math.floor(ROWS / 2), rc = Math.floor(COLS / 2);
const NUM_BEAMS = 36;
for (let b = 0; b < NUM_BEAMS; b++) {
const angle = (b / NUM_BEAMS) * Math.PI * 2;
const dr = Math.sin(angle), dc = Math.cos(angle);
const cells = castRay(rr, rc, dr, dc);
for (let i = 0; i < cells.length; i++) {
const { ri, ci, hit } = cells[i];
if (hit) {
logOdds[ri][ci] = Math.min(logOdds[ri][ci] + L_OCC, L_CLAMP);
} else {
logOdds[ri][ci] = Math.max(logOdds[ri][ci] + L_FREE, -L_CLAMP);
}
}
}
scanCount++;
scanCountEl.textContent = i18nFmt('Scans: %n%', { n: scanCount });
statusEl.textContent = i18nFmt('After %n% scan(s) — walls are emerging from noise.', { n: scanCount });
drawMap();
}
function reset() {
logOdds = Array.from({ length: ROWS }, () => new Float32Array(COLS));
scanCount = 0;
scanCountEl.textContent = '';
statusEl.textContent = 'Press Scan to fire a sweep of beams.';
stopAuto();
drawMap();
}
function stopAuto() {
if (autoTimer !== null) { clearInterval(autoTimer); autoTimer = null; }
btnAuto.textContent = 'Auto';
btnAuto.classList.remove('active');
}
function toggleAuto() {
if (autoTimer !== null) {
stopAuto();
} else {
autoTimer = setInterval(scan, 180);
btnAuto.textContent = 'Stop';
btnAuto.classList.add('active');
}
}
document.getElementById('btn-scan').addEventListener('click', scan);
document.getElementById('btn-auto').addEventListener('click', toggleAuto);
document.getElementById('btn-reset').addEventListener('click', reset);
drawTruth();
drawMap();
<!-- Occupancy-grid mapping demo: true room on the left, probabilistic map on the right -->
<div class="toolbar">
<button id="btn-scan" type="button">Scan</button>
<button id="btn-auto" type="button">Auto</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
<span class="scan-count" id="scan-count"></span>
</div>
<div class="panels">
<div class="panel">
<div class="panel-label">True room</div>
<canvas id="truth-canvas" width="200" height="200"></canvas>
</div>
<div class="panel">
<div class="panel-label">Robot's map</div>
<canvas id="map-canvas" width="200" height="200"></canvas>
</div>
</div>
<div class="status" id="status">Press Scan to fire a sweep of beams.</div>
/* Styles for the two-panel occupancy-grid mapping demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.toolbar { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin-bottom: .6rem; }
button { font: 600 13px system-ui, sans-serif; padding: .38rem .75rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #e63946; border-color: #c92f3c; }
.scan-count { font-size: .85rem; color: #555; margin-left: .3rem; }
.panels { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { display: flex; flex-direction: column; align-items: center; gap: .25rem; }
.panel-label { font-size: .8rem; font-weight: 600; color: #555; letter-spacing: .03em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; image-rendering: pixelated; display: block; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Grid dimensions — each cell is one square in the occupancy grid
const COLS = 20, ROWS = 20;
const CELL = 10; // pixels per cell on canvas
// Log-odds update constants derived from the sensor model
const L_OCC = Math.log(0.85 / 0.15); // added to a cell when the beam hits it (occupied evidence)
const L_FREE = Math.log(0.15 / 0.85); // added to a cell the beam passes through (free evidence)
const L_CLAMP = 6; // clamp log-odds so probabilities stay away from 0 and 1
// Ground-truth room: border walls + one inner pillar
// 1 = wall, 0 = free. The room is a rectangle with an inner pillar.
const ROOM = (() => {
const g = [];
for (let r = 0; r < ROWS; r++) {
const row = [];
for (let c = 0; c < COLS; c++) {
const onEdge = r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1;
const pillar = r >= 7 && r <= 9 && c >= 7 && c <= 9;
row.push(onEdge || pillar ? 1 : 0);
}
g.push(row);
}
return g;
})();
// Log-odds grid initialised to 0 (p=0.5, maximum uncertainty)
let logOdds = Array.from({ length: ROWS }, () => new Float32Array(COLS));
let scanCount = 0;
let autoTimer = null;
const truthCtx = document.getElementById('truth-canvas').getContext('2d');
const mapCtx = document.getElementById('map-canvas').getContext('2d');
const statusEl = document.getElementById('status');
const scanCountEl = document.getElementById('scan-count');
const btnAuto = document.getElementById('btn-auto');
// Render the ground-truth room: dark = wall, light = free
function drawTruth() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
truthCtx.fillStyle = ROOM[r][c] ? '#1d3557' : '#f0f4f8';
truthCtx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// red dot marks the robot position
const rx = Math.floor(COLS / 2) * CELL + CELL / 2;
const ry = Math.floor(ROWS / 2) * CELL + CELL / 2;
truthCtx.beginPath();
truthCtx.arc(rx, ry, 4, 0, Math.PI * 2);
truthCtx.fillStyle = '#e63946';
truthCtx.fill();
}
// Render the probabilistic map: dark = likely occupied, light = likely free
function drawMap() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const l = logOdds[r][c];
const p = 1 - 1 / (1 + Math.exp(l));
// convert probability to grayscale: p=1 (occupied) -> black, p=0 (free) -> white
const gray = Math.round((1 - p) * 255);
mapCtx.fillStyle = `rgb(${gray},${gray},${gray})`;
mapCtx.fillRect(c * CELL, r * CELL, CELL, CELL);
}
}
// robot position on the map
const rx = Math.floor(COLS / 2) * CELL + CELL / 2;
const ry = Math.floor(ROWS / 2) * CELL + CELL / 2;
mapCtx.beginPath();
mapCtx.arc(rx, ry, 4, 0, Math.PI * 2);
mapCtx.fillStyle = '#e63946';
mapCtx.fill();
}
// Ray-casting: march in direction (dr,dc) until hitting a wall or leaving the grid
// Cast a ray from (r0,c0) in direction (dr,dc). Returns cells along the beam.
function castRay(r0, c0, dr, dc) {
const cells = [];
let r = r0, c = c0;
const maxSteps = Math.max(ROWS, COLS) * 2;
for (let i = 0; i < maxSteps; i++) {
r += dr; c += dc;
const ri = Math.round(r), ci = Math.round(c);
if (ri < 0 || ri >= ROWS || ci < 0 || ci >= COLS) break;
cells.push({ ri, ci, hit: ROOM[ri][ci] === 1 });
if (ROOM[ri][ci] === 1) break; // stop ray at first wall hit
}
return cells;
}
// One scan: cast 36 beams from the robot centre and update log-odds
function scan() {
const rr = Math.floor(ROWS / 2), rc = Math.floor(COLS / 2);
const NUM_BEAMS = 36;
for (let b = 0; b < NUM_BEAMS; b++) {
const angle = (b / NUM_BEAMS) * Math.PI * 2;
const dr = Math.sin(angle), dc = Math.cos(angle);
const cells = castRay(rr, rc, dr, dc);
for (let i = 0; i < cells.length; i++) {
const { ri, ci, hit } = cells[i];
if (hit) {
logOdds[ri][ci] = Math.min(logOdds[ri][ci] + L_OCC, L_CLAMP);
} else {
logOdds[ri][ci] = Math.max(logOdds[ri][ci] + L_FREE, -L_CLAMP);
}
}
}
scanCount++;
scanCountEl.textContent = i18nFmt('Scans: %n%', { n: scanCount });
statusEl.textContent = i18nFmt('After %n% scan(s) — walls are emerging from noise.', { n: scanCount });
drawMap();
}
function reset() {
logOdds = Array.from({ length: ROWS }, () => new Float32Array(COLS));
scanCount = 0;
scanCountEl.textContent = '';
statusEl.textContent = 'Press Scan to fire a sweep of beams.';
stopAuto();
drawMap();
}
function stopAuto() {
if (autoTimer !== null) { clearInterval(autoTimer); autoTimer = null; }
btnAuto.textContent = 'Auto';
btnAuto.classList.remove('active');
}
function toggleAuto() {
if (autoTimer !== null) {
stopAuto();
} else {
autoTimer = setInterval(scan, 180);
btnAuto.textContent = 'Stop';
btnAuto.classList.add('active');
}
}
document.getElementById('btn-scan').addEventListener('click', scan);
document.getElementById('btn-auto').addEventListener('click', toggleAuto);
document.getElementById('btn-reset').addEventListener('click', reset);
drawTruth();
drawMap();
Press Scan to fire one sweep of beams and watch the grid update. Press Auto to let the robot scan continuously. Notice how a few scans produce a blurry, uncertain map, while after many sweeps the walls become crisp and dark and the interior turns confidently white. The Reset button restores all cells to p=0.5.
The math behind each update is a single application of Bayes' rule: the new probability is proportional to the likelihood of the reading given occupancy, times the old probability. Working in log-odds makes it an addition rather than a multiplication, which is why the algorithm is fast enough to run on real hardware.
Comments
Loading comments...