Click anywhere on the left canvas to place edge points. Each point you add casts a vote for every line through it — drawn as a sinusoidal curve in the right accumulator panel. Where many curves cross, votes pile up into a bright peak. That peak is the line the algorithm detects.
<!-- Hough Transform demo: left canvas = image space, right canvas = Hough accumulator -->
<div class="hint-bar">Click the left canvas to place edge points. Each point casts votes for all lines through it — shown as a sinusoidal curve in the right accumulator. Lines with many votes glow red.</div>
<div class="panels">
<div class="panel-wrap">
<div class="panel-label">Image space</div>
<canvas id="imgCanvas" width="200" height="200"></canvas>
</div>
<div class="panel-wrap">
<div class="panel-label">Hough accumulator</div>
<canvas id="accumCanvas" width="200" height="200"></canvas>
<div class="accum-axes">
<span class="axis-x">θ →</span>
<span class="axis-y">↑ ρ</span>
</div>
</div>
</div>
<div class="status" id="status">Click the canvas or add random points.</div>
<div class="btns">
<button id="btnRandom" type="button">Add random points</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Layout: two side-by-side canvases with labels and control buttons */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint-bar { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.panels { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: .5rem; }
.panel-wrap { display: flex; flex-direction: column; align-items: center; }
.panel-label { font-size: .75rem; font-weight: 600; color: #555; margin-bottom: 4px; letter-spacing: .04em; text-transform: uppercase; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; cursor: crosshair; display: block; }
#accumCanvas { cursor: default; }
.accum-axes { display: flex; justify-content: space-between; width: 200px; font-size: .7rem; color: #888; margin-top: 2px; }
.axis-y { position: relative; right: -192px; top: -218px; }
.status { font-size: .9rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.info { color: #1d3557; }
.status.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Hough parameter space: theta in [0, pi) mapped to THETA_STEPS columns,
// rho in [-RHO_MAX, RHO_MAX] mapped to RHO_STEPS rows in the accumulator grid.
const W = 200, H = 200;
const THETA_STEPS = 180;
const RHO_MAX = Math.round(Math.sqrt(W * W + H * H));
const RHO_STEPS = 180;
const imgCanvas = document.getElementById('imgCanvas');
const accumCanvas = document.getElementById('accumCanvas');
const imgCtx = imgCanvas.getContext('2d');
const accumCtx = accumCanvas.getContext('2d');
// Global state: list of placed points and the flat 2-D accumulator array.
let points = [];
let accum = new Array(THETA_STEPS * RHO_STEPS).fill(0);
let maxVotes = 0;
// Render the image canvas: white background with blue dots at each point.
function drawImage() {
imgCtx.clearRect(0, 0, W, H);
imgCtx.fillStyle = '#f5f8fa';
imgCtx.fillRect(0, 0, W, H);
imgCtx.fillStyle = '#1d3557';
for (const [px, py] of points) {
imgCtx.beginPath();
imgCtx.arc(px, py, 3, 0, Math.PI * 2);
imgCtx.fill();
}
}
// Render accumulator as a heat-map: blue=0 votes, red=max votes.
function drawAccum() {
const imgData = accumCtx.createImageData(W, H);
const d = imgData.data;
for (let t = 0; t < THETA_STEPS; t++) {
for (let r = 0; r < RHO_STEPS; r++) {
const v = maxVotes > 0 ? accum[t * RHO_STEPS + r] / maxVotes : 0;
const bright = Math.round(v * 255);
const i = (r * W + t) * 4;
d[i] = bright;
d[i + 1] = Math.round(bright * 0.6);
d[i + 2] = 255 - bright;
d[i + 3] = 255;
}
}
accumCtx.putImageData(imgData, 0, 0);
// Highlight the cell with the most votes (the detected line parameters).
if (maxVotes >= 2) {
let bestT = 0, bestR = 0;
for (let t = 0; t < THETA_STEPS; t++) {
for (let r = 0; r < RHO_STEPS; r++) {
if (accum[t * RHO_STEPS + r] > accum[bestT * RHO_STEPS + bestR]) {
bestT = t; bestR = r;
}
}
}
accumCtx.strokeStyle = '#e63946';
accumCtx.lineWidth = 1.5;
accumCtx.strokeRect(bestT - 4, bestR - 4, 9, 9);
}
}
// For a new point (px,py), increment every accumulator cell it votes for.
function castVotes(px, py) {
for (let ti = 0; ti < THETA_STEPS; ti++) {
const theta = (ti * Math.PI) / THETA_STEPS;
const rho = px * Math.cos(theta) + py * Math.sin(theta);
const ri = Math.round(((rho + RHO_MAX) / (2 * RHO_MAX)) * (RHO_STEPS - 1));
if (ri >= 0 && ri < RHO_STEPS) {
accum[ti * RHO_STEPS + ri]++;
if (accum[ti * RHO_STEPS + ri] > maxVotes) maxVotes = accum[ti * RHO_STEPS + ri];
}
}
}
function addPoint(px, py) {
points.push([px, py]);
castVotes(px, py);
drawImage();
drawAccum();
setStatus(i18nFmt('%n% point(s) placed — watch the accumulator.', { n: points.length }), 'info');
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
// Handle clicks on the image canvas: add a point at the clicked pixel.
imgCanvas.addEventListener('click', (e) => {
const rect = imgCanvas.getBoundingClientRect();
const px = Math.round((e.clientX - rect.left) * (W / rect.width));
const py = Math.round((e.clientY - rect.top) * (H / rect.height));
addPoint(px, py);
});
// Scatter ~12 noisy collinear points along a random line.
document.getElementById('btnRandom').addEventListener('click', () => {
const cx = 50 + Math.random() * 100;
const cy = 50 + Math.random() * 100;
const angle = Math.random() * Math.PI;
const cos = Math.cos(angle), sin = Math.sin(angle);
for (let i = 0; i < 12; i++) {
const t = -80 + i * 14;
const noise = (Math.random() - 0.5) * 12;
const px = Math.round(cx + cos * t + (Math.random() - 0.5) * noise);
const py = Math.round(cy + sin * t + (Math.random() - 0.5) * noise);
if (px >= 0 && px < W && py >= 0 && py < H) addPoint(px, py);
}
});
document.getElementById('btnReset').addEventListener('click', () => {
points = [];
accum = new Array(THETA_STEPS * RHO_STEPS).fill(0);
maxVotes = 0;
drawImage();
drawAccum();
setStatus('Click the canvas or add random points.', '');
});
// Initial render: blank image canvas and blank (light) accumulator.
drawImage();
accumCtx.fillStyle = '#f5f8fa';
accumCtx.fillRect(0, 0, W, H);
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Hough parameter space: theta in [0, pi) mapped to THETA_STEPS columns,
// rho in [-RHO_MAX, RHO_MAX] mapped to RHO_STEPS rows in the accumulator grid.
const W = 200, H = 200;
const THETA_STEPS = 180;
const RHO_MAX = Math.round(Math.sqrt(W * W + H * H));
const RHO_STEPS = 180;
const imgCanvas = document.getElementById('imgCanvas');
const accumCanvas = document.getElementById('accumCanvas');
const imgCtx = imgCanvas.getContext('2d');
const accumCtx = accumCanvas.getContext('2d');
// Global state: list of placed points and the flat 2-D accumulator array.
let points = [];
let accum = new Array(THETA_STEPS * RHO_STEPS).fill(0);
let maxVotes = 0;
// Render the image canvas: white background with blue dots at each point.
function drawImage() {
imgCtx.clearRect(0, 0, W, H);
imgCtx.fillStyle = '#f5f8fa';
imgCtx.fillRect(0, 0, W, H);
imgCtx.fillStyle = '#1d3557';
for (const [px, py] of points) {
imgCtx.beginPath();
imgCtx.arc(px, py, 3, 0, Math.PI * 2);
imgCtx.fill();
}
}
// Render accumulator as a heat-map: blue=0 votes, red=max votes.
function drawAccum() {
const imgData = accumCtx.createImageData(W, H);
const d = imgData.data;
for (let t = 0; t < THETA_STEPS; t++) {
for (let r = 0; r < RHO_STEPS; r++) {
const v = maxVotes > 0 ? accum[t * RHO_STEPS + r] / maxVotes : 0;
const bright = Math.round(v * 255);
const i = (r * W + t) * 4;
d[i] = bright;
d[i + 1] = Math.round(bright * 0.6);
d[i + 2] = 255 - bright;
d[i + 3] = 255;
}
}
accumCtx.putImageData(imgData, 0, 0);
// Highlight the cell with the most votes (the detected line parameters).
if (maxVotes >= 2) {
let bestT = 0, bestR = 0;
for (let t = 0; t < THETA_STEPS; t++) {
for (let r = 0; r < RHO_STEPS; r++) {
if (accum[t * RHO_STEPS + r] > accum[bestT * RHO_STEPS + bestR]) {
bestT = t; bestR = r;
}
}
}
accumCtx.strokeStyle = '#e63946';
accumCtx.lineWidth = 1.5;
accumCtx.strokeRect(bestT - 4, bestR - 4, 9, 9);
}
}
// For a new point (px,py), increment every accumulator cell it votes for.
function castVotes(px, py) {
for (let ti = 0; ti < THETA_STEPS; ti++) {
const theta = (ti * Math.PI) / THETA_STEPS;
const rho = px * Math.cos(theta) + py * Math.sin(theta);
const ri = Math.round(((rho + RHO_MAX) / (2 * RHO_MAX)) * (RHO_STEPS - 1));
if (ri >= 0 && ri < RHO_STEPS) {
accum[ti * RHO_STEPS + ri]++;
if (accum[ti * RHO_STEPS + ri] > maxVotes) maxVotes = accum[ti * RHO_STEPS + ri];
}
}
}
function addPoint(px, py) {
points.push([px, py]);
castVotes(px, py);
drawImage();
drawAccum();
setStatus(i18nFmt('%n% point(s) placed — watch the accumulator.', { n: points.length }), 'info');
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
// Handle clicks on the image canvas: add a point at the clicked pixel.
imgCanvas.addEventListener('click', (e) => {
const rect = imgCanvas.getBoundingClientRect();
const px = Math.round((e.clientX - rect.left) * (W / rect.width));
const py = Math.round((e.clientY - rect.top) * (H / rect.height));
addPoint(px, py);
});
// Scatter ~12 noisy collinear points along a random line.
document.getElementById('btnRandom').addEventListener('click', () => {
const cx = 50 + Math.random() * 100;
const cy = 50 + Math.random() * 100;
const angle = Math.random() * Math.PI;
const cos = Math.cos(angle), sin = Math.sin(angle);
for (let i = 0; i < 12; i++) {
const t = -80 + i * 14;
const noise = (Math.random() - 0.5) * 12;
const px = Math.round(cx + cos * t + (Math.random() - 0.5) * noise);
const py = Math.round(cy + sin * t + (Math.random() - 0.5) * noise);
if (px >= 0 && px < W && py >= 0 && py < H) addPoint(px, py);
}
});
document.getElementById('btnReset').addEventListener('click', () => {
points = [];
accum = new Array(THETA_STEPS * RHO_STEPS).fill(0);
maxVotes = 0;
drawImage();
drawAccum();
setStatus('Click the canvas or add random points.', '');
});
// Initial render: blank image canvas and blank (light) accumulator.
drawImage();
accumCtx.fillStyle = '#f5f8fa';
accumCtx.fillRect(0, 0, W, H);
Hit Add random points to scatter a set of noisy collinear points and watch the accumulator concentrate. The Reset button clears everything. Notice that even when several points are off the true line, the peak remains sharp — the transform is robust to noise precisely because it requires only a majority of points to agree, not unanimity.
Comments
Loading comments...