Below is a sparse signal: 64 values in the frequency domain, only a few of them non-zero. The demo takes a random subset of measurements (far fewer than 64) and then recovers the original signal using ℓ1 minimization.
<p class="hint">A <b>sparse signal</b> has only a few non-zero frequencies. Slide to control how many random measurements are taken, then click <b>Recover Signal</b> to reconstruct it from those measurements using basis pursuit (ℓ₁ minimization via ISTA).</p>
<div class="controls">
<label>Measurements: <b id="mLabel">20</b> of 64
<input type="range" id="mSlider" min="8" max="64" value="20">
</label>
<label>Sparsity (non-zero tones): <b id="kLabel">4</b>
<input type="range" id="kSlider" min="1" max="12" value="4">
</label>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-title">Original signal (frequency domain)</div>
<canvas id="origCanvas" width="300" height="100"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">Recovered signal</div>
<canvas id="recCanvas" width="300" height="100"></canvas>
</div>
</div>
<div class="btns">
<button id="newSignal" type="button">New random signal</button>
<button id="recover" type="button">Recover Signal</button>
</div>
<div class="status" id="status">Click "Recover Signal" to reconstruct from measurements.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .87rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .8rem; font-size: .88rem; }
label { display: flex; align-items: center; gap: .4rem; }
input[type=range] { flex: 1; max-width: 180px; }
.chart-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: .7rem; }
.chart-box { flex: 1 1 280px; }
.chart-title { font-size: .8rem; color: #555; margin-bottom: 3px; font-weight: 600; }
canvas { width: 100%; height: 100px; border: 1px solid #d0d7de; border-radius: 6px; background: #f8fafb; display: block; }
.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; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #444; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Compressed Sensing demo
// Signal: sparse in the frequency domain (N=64 real DFT bins).
// Measurement: Phi is m x N random Gaussian matrix (normalized columns).
// Recovery: ISTA (Iterative Shrinkage-Thresholding) to solve
// minimize ||x||_1 subject to Phi*x ≈ y
// which is equivalent to basis pursuit for noiseless case.
const N = 64;
let origX = new Float64Array(N); // sparse frequency coefficients
let mCount = 20;
let kCount = 4;
// --- Utility ---
function randn() {
// Box-Muller
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
// Build m x N measurement matrix with iid N(0,1/m) entries
function buildPhi(m, n) {
const phi = [];
for (let i = 0; i < m; i++) {
phi[i] = new Float64Array(n);
for (let j = 0; j < n; j++) phi[i][j] = randn() / Math.sqrt(m);
}
return phi;
}
// Matrix-vector product: Phi * x (returns Float64Array of length m)
function matvec(phi, x) {
const m = phi.length, n = x.length;
const y = new Float64Array(m);
for (let i = 0; i < m; i++) {
let s = 0;
for (let j = 0; j < n; j++) s += phi[i][j] * x[j];
y[i] = s;
}
return y;
}
// Matrix-vector product: Phi^T * r (returns Float64Array of length n)
function matvecT(phi, r) {
const m = phi.length, n = phi[0].length;
const out = new Float64Array(n);
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) out[j] += phi[i][j] * r[i];
}
return out;
}
// Soft-threshold: sign(x) * max(|x|-lambda, 0)
function softThresh(x, lam) {
const out = new Float64Array(x.length);
for (let i = 0; i < x.length; i++) {
const abs = Math.abs(x[i]);
out[i] = abs > lam ? (x[i] > 0 ? 1 : -1) * (abs - lam) : 0;
}
return out;
}
// ISTA for basis pursuit: minimize ||x||_1 s.t. Phi*x = y
// Uses step size 1/L where L = largest eigenvalue estimate (power iteration)
function ista(phi, y, iters) {
const n = phi[0].length;
// Estimate spectral norm of Phi^T Phi via a few power iterations
let v = new Float64Array(n).fill(1);
for (let t = 0; t < 15; t++) {
const tmp = matvecT(phi, matvec(phi, v));
let norm = 0;
for (let j = 0; j < n; j++) norm += tmp[j] * tmp[j];
norm = Math.sqrt(norm);
if (norm < 1e-12) break;
for (let j = 0; j < n; j++) v[j] = tmp[j] / norm;
}
const tmp2 = matvecT(phi, matvec(phi, v));
let L = 0;
for (let j = 0; j < n; j++) L += tmp2[j] * v[j];
if (L < 1e-12) L = 1;
const step = 1 / L;
// lambda starts high and decreases (continuation)
const PhiTy = matvecT(phi, y);
let maxPhiTy = 0;
for (let j = 0; j < n; j++) if (Math.abs(PhiTy[j]) > maxPhiTy) maxPhiTy = Math.abs(PhiTy[j]);
let lambda = maxPhiTy * 0.5;
let x = new Float64Array(n);
for (let stage = 0; stage < 6; stage++) {
lambda *= 0.35;
for (let t = 0; t < iters; t++) {
const res = matvec(phi, x);
for (let i = 0; i < res.length; i++) res[i] = y[i] - res[i];
const grad = matvecT(phi, res);
const xNew = new Float64Array(n);
for (let j = 0; j < n; j++) xNew[j] = x[j] + step * grad[j];
x = softThresh(xNew, lambda * step);
}
}
return x;
}
// Generate a new sparse signal
function genSignal(k, n) {
const x = new Float64Array(n);
const indices = [];
while (indices.length < k) {
const idx = Math.floor(Math.random() * n);
if (!indices.includes(idx)) indices.push(idx);
}
for (const idx of indices) {
x[idx] = (Math.random() > 0.5 ? 1 : -1) * (0.5 + Math.random() * 0.5);
}
return x;
}
// Drawing
function drawBars(canvas, data, color, highlightColor) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
let maxVal = 0.01;
for (let i = 0; i < data.length; i++) if (Math.abs(data[i]) > maxVal) maxVal = Math.abs(data[i]);
const barW = W / data.length;
const midY = H / 2;
// zero line
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
for (let i = 0; i < data.length; i++) {
const h = (Math.abs(data[i]) / maxVal) * (H / 2 - 4);
const isNZ = Math.abs(data[i]) > 0.02;
ctx.fillStyle = isNZ ? highlightColor : color;
if (data[i] >= 0) {
ctx.fillRect(i * barW + 1, midY - h, barW - 1, h);
} else {
ctx.fillRect(i * barW + 1, midY, barW - 1, h);
}
}
}
function l1norm(x) { let s=0; for(let i=0;i<x.length;i++) s+=Math.abs(x[i]); return s; }
function l2err(a, b) { let s=0; for(let i=0;i<a.length;i++) s+=(a[i]-b[i])**2; return Math.sqrt(s); }
function updateLabels() {
document.getElementById('mLabel').textContent = mCount;
document.getElementById('kLabel').textContent = kCount;
}
function doRecover() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Computing recovery...';
statusEl.className = 'status info';
// Give the browser a chance to repaint
setTimeout(() => {
const phi = buildPhi(mCount, N);
const y = matvec(phi, origX);
const recX = ista(phi, y, 80);
drawBars(document.getElementById('recCanvas'), recX, '#93b4cc', '#1d6fa6');
const err = l2err(origX, recX);
const rel = err / (l1norm(origX) + 1e-12);
if (rel < 0.05) {
statusEl.textContent = fmt('✓ Perfect recovery! Error: %err% (%mCount% measurements of %N%)', { err: err.toFixed(4), mCount: mCount, N: N });
statusEl.className = 'status ok';
} else if (rel < 0.25) {
statusEl.textContent = fmt('~ Approximate recovery. Error: %err% — try more measurements.', { err: err.toFixed(4) });
statusEl.className = 'status bad';
} else {
statusEl.textContent = fmt('✗ Poor recovery. Error: %err% — too few measurements for this sparsity.', { err: err.toFixed(4) });
statusEl.className = 'status bad';
}
console.log(fmt('CS demo | m=%mCount% k=%kCount% N=%N% err=%err%', { mCount: mCount, kCount: kCount, N: N, err: err.toFixed(6) }));
}, 10);
}
function init() {
origX = genSignal(kCount, N);
drawBars(document.getElementById('origCanvas'), origX, '#a8c6dc', '#1d6fa6');
drawBars(document.getElementById('recCanvas'), new Float64Array(N), '#dde3e8', '#999');
document.getElementById('status').textContent = 'Click "Recover Signal" to reconstruct from measurements.';
document.getElementById('status').className = 'status info';
}
document.getElementById('mSlider').addEventListener('input', e => { mCount = +e.target.value; updateLabels(); });
document.getElementById('kSlider').addEventListener('input', e => { kCount = +e.target.value; updateLabels(); });
document.getElementById('newSignal').addEventListener('click', init);
document.getElementById('recover').addEventListener('click', doRecover);
updateLabels();
init();
<p class="hint">A <b>sparse signal</b> has only a few non-zero frequencies. Slide to control how many random measurements are taken, then click <b>Recover Signal</b> to reconstruct it from those measurements using basis pursuit (ℓ₁ minimization via ISTA).</p>
<div class="controls">
<label>Measurements: <b id="mLabel">20</b> of 64
<input type="range" id="mSlider" min="8" max="64" value="20">
</label>
<label>Sparsity (non-zero tones): <b id="kLabel">4</b>
<input type="range" id="kSlider" min="1" max="12" value="4">
</label>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-title">Original signal (frequency domain)</div>
<canvas id="origCanvas" width="300" height="100"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">Recovered signal</div>
<canvas id="recCanvas" width="300" height="100"></canvas>
</div>
</div>
<div class="btns">
<button id="newSignal" type="button">New random signal</button>
<button id="recover" type="button">Recover Signal</button>
</div>
<div class="status" id="status">Click "Recover Signal" to reconstruct from measurements.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .87rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .8rem; font-size: .88rem; }
label { display: flex; align-items: center; gap: .4rem; }
input[type=range] { flex: 1; max-width: 180px; }
.chart-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: .7rem; }
.chart-box { flex: 1 1 280px; }
.chart-title { font-size: .8rem; color: #555; margin-bottom: 3px; font-weight: 600; }
canvas { width: 100%; height: 100px; border: 1px solid #d0d7de; border-radius: 6px; background: #f8fafb; display: block; }
.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; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.3em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #444; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Compressed Sensing demo
// Signal: sparse in the frequency domain (N=64 real DFT bins).
// Measurement: Phi is m x N random Gaussian matrix (normalized columns).
// Recovery: ISTA (Iterative Shrinkage-Thresholding) to solve
// minimize ||x||_1 subject to Phi*x ≈ y
// which is equivalent to basis pursuit for noiseless case.
const N = 64;
let origX = new Float64Array(N); // sparse frequency coefficients
let mCount = 20;
let kCount = 4;
// --- Utility ---
function randn() {
// Box-Muller
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
// Build m x N measurement matrix with iid N(0,1/m) entries
function buildPhi(m, n) {
const phi = [];
for (let i = 0; i < m; i++) {
phi[i] = new Float64Array(n);
for (let j = 0; j < n; j++) phi[i][j] = randn() / Math.sqrt(m);
}
return phi;
}
// Matrix-vector product: Phi * x (returns Float64Array of length m)
function matvec(phi, x) {
const m = phi.length, n = x.length;
const y = new Float64Array(m);
for (let i = 0; i < m; i++) {
let s = 0;
for (let j = 0; j < n; j++) s += phi[i][j] * x[j];
y[i] = s;
}
return y;
}
// Matrix-vector product: Phi^T * r (returns Float64Array of length n)
function matvecT(phi, r) {
const m = phi.length, n = phi[0].length;
const out = new Float64Array(n);
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) out[j] += phi[i][j] * r[i];
}
return out;
}
// Soft-threshold: sign(x) * max(|x|-lambda, 0)
function softThresh(x, lam) {
const out = new Float64Array(x.length);
for (let i = 0; i < x.length; i++) {
const abs = Math.abs(x[i]);
out[i] = abs > lam ? (x[i] > 0 ? 1 : -1) * (abs - lam) : 0;
}
return out;
}
// ISTA for basis pursuit: minimize ||x||_1 s.t. Phi*x = y
// Uses step size 1/L where L = largest eigenvalue estimate (power iteration)
function ista(phi, y, iters) {
const n = phi[0].length;
// Estimate spectral norm of Phi^T Phi via a few power iterations
let v = new Float64Array(n).fill(1);
for (let t = 0; t < 15; t++) {
const tmp = matvecT(phi, matvec(phi, v));
let norm = 0;
for (let j = 0; j < n; j++) norm += tmp[j] * tmp[j];
norm = Math.sqrt(norm);
if (norm < 1e-12) break;
for (let j = 0; j < n; j++) v[j] = tmp[j] / norm;
}
const tmp2 = matvecT(phi, matvec(phi, v));
let L = 0;
for (let j = 0; j < n; j++) L += tmp2[j] * v[j];
if (L < 1e-12) L = 1;
const step = 1 / L;
// lambda starts high and decreases (continuation)
const PhiTy = matvecT(phi, y);
let maxPhiTy = 0;
for (let j = 0; j < n; j++) if (Math.abs(PhiTy[j]) > maxPhiTy) maxPhiTy = Math.abs(PhiTy[j]);
let lambda = maxPhiTy * 0.5;
let x = new Float64Array(n);
for (let stage = 0; stage < 6; stage++) {
lambda *= 0.35;
for (let t = 0; t < iters; t++) {
const res = matvec(phi, x);
for (let i = 0; i < res.length; i++) res[i] = y[i] - res[i];
const grad = matvecT(phi, res);
const xNew = new Float64Array(n);
for (let j = 0; j < n; j++) xNew[j] = x[j] + step * grad[j];
x = softThresh(xNew, lambda * step);
}
}
return x;
}
// Generate a new sparse signal
function genSignal(k, n) {
const x = new Float64Array(n);
const indices = [];
while (indices.length < k) {
const idx = Math.floor(Math.random() * n);
if (!indices.includes(idx)) indices.push(idx);
}
for (const idx of indices) {
x[idx] = (Math.random() > 0.5 ? 1 : -1) * (0.5 + Math.random() * 0.5);
}
return x;
}
// Drawing
function drawBars(canvas, data, color, highlightColor) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
let maxVal = 0.01;
for (let i = 0; i < data.length; i++) if (Math.abs(data[i]) > maxVal) maxVal = Math.abs(data[i]);
const barW = W / data.length;
const midY = H / 2;
// zero line
ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke();
for (let i = 0; i < data.length; i++) {
const h = (Math.abs(data[i]) / maxVal) * (H / 2 - 4);
const isNZ = Math.abs(data[i]) > 0.02;
ctx.fillStyle = isNZ ? highlightColor : color;
if (data[i] >= 0) {
ctx.fillRect(i * barW + 1, midY - h, barW - 1, h);
} else {
ctx.fillRect(i * barW + 1, midY, barW - 1, h);
}
}
}
function l1norm(x) { let s=0; for(let i=0;i<x.length;i++) s+=Math.abs(x[i]); return s; }
function l2err(a, b) { let s=0; for(let i=0;i<a.length;i++) s+=(a[i]-b[i])**2; return Math.sqrt(s); }
function updateLabels() {
document.getElementById('mLabel').textContent = mCount;
document.getElementById('kLabel').textContent = kCount;
}
function doRecover() {
const statusEl = document.getElementById('status');
statusEl.textContent = 'Computing recovery...';
statusEl.className = 'status info';
// Give the browser a chance to repaint
setTimeout(() => {
const phi = buildPhi(mCount, N);
const y = matvec(phi, origX);
const recX = ista(phi, y, 80);
drawBars(document.getElementById('recCanvas'), recX, '#93b4cc', '#1d6fa6');
const err = l2err(origX, recX);
const rel = err / (l1norm(origX) + 1e-12);
if (rel < 0.05) {
statusEl.textContent = fmt('✓ Perfect recovery! Error: %err% (%mCount% measurements of %N%)', { err: err.toFixed(4), mCount: mCount, N: N });
statusEl.className = 'status ok';
} else if (rel < 0.25) {
statusEl.textContent = fmt('~ Approximate recovery. Error: %err% — try more measurements.', { err: err.toFixed(4) });
statusEl.className = 'status bad';
} else {
statusEl.textContent = fmt('✗ Poor recovery. Error: %err% — too few measurements for this sparsity.', { err: err.toFixed(4) });
statusEl.className = 'status bad';
}
console.log(fmt('CS demo | m=%mCount% k=%kCount% N=%N% err=%err%', { mCount: mCount, kCount: kCount, N: N, err: err.toFixed(6) }));
}, 10);
}
function init() {
origX = genSignal(kCount, N);
drawBars(document.getElementById('origCanvas'), origX, '#a8c6dc', '#1d6fa6');
drawBars(document.getElementById('recCanvas'), new Float64Array(N), '#dde3e8', '#999');
document.getElementById('status').textContent = 'Click "Recover Signal" to reconstruct from measurements.';
document.getElementById('status').className = 'status info';
}
document.getElementById('mSlider').addEventListener('input', e => { mCount = +e.target.value; updateLabels(); });
document.getElementById('kSlider').addEventListener('input', e => { kCount = +e.target.value; updateLabels(); });
document.getElementById('newSignal').addEventListener('click', init);
document.getElementById('recover').addEventListener('click', doRecover);
updateLabels();
init();
Comments
Loading comments...