2-D Convolution
Nine numbers decide blur or edge.
Beginner / kernels / filtering / linear-operators
A 3x3 kernel slides over the image, multiplying and summing - that is the whole algorithm. Box blur, sharpen, edge detection, and emboss are the same code with different kernel values, which is the deepest fact in image processing: the filter is data.
Tagged: kernel, blur, edge detect, sharpen
Live simulation
Blur, sharpen, edge - the same nine numbers in a different arrangement. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Blur, sharpen, edge - the same nine numbers in a different arrangement.
Mathematics
Convolution slides a small kernel over the image, multiplying and summing at each position: out(x, y) = sum k_ij * in(x + i, y + j). It is a linear operator, and the kernel is its entire specification - blur, sharpen, and edge detection differ only in nine numbers.
out(x, y) = sum_{i,j} k_{ij} * in(x + i - 1, y + j - 1)
box blur kernel: k_ij = 1/9
edge kernel: [[0,1,0],[1,-4,1],[0,1,0]] (zero response on flat regions)
The edge kernel's rows and columns sum to zero, so flat regions produce exactly zero and only gradients respond - that is why it 'detects' edges rather than brightening them. The blur kernel sums to one, so flat regions stay the same brightness: kernel sums are the first thing to check about any filter.
How it works
The source image is procedural (same generator as the SVD page); the convolution walks every pixel with a clamped 3x3 neighborhood and writes the response through a split view - original left, filtered right.
Boundary handling is clamping (replicate edges), the simplest honest choice for a demo.
Implementation
src/engine/experiments/convolution.ts · 158 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | const SIZE = 96; |
| 5 | |
| 6 | /** |
| 7 | * 2-D convolution: a 3x3 kernel slides over the image and the response is |
| 8 | * the filtered output. Blur, sharpen, and edge detection are the SAME code |
| 9 | * with different numbers in the kernel - which is the entire lesson. |
| 10 | */ |
| 11 | export class ConvolutionExperiment extends BaseExperiment { |
| 12 | readonly id = "convolution"; |
| 13 | |
| 14 | private original = new Float64Array(SIZE * SIZE); |
| 15 | private filtered = new Float64Array(SIZE * SIZE); |
| 16 | private image: ImageData | null = null; |
| 17 | private scratch: HTMLCanvasElement | null = null; |
| 18 | private needsCompute = true; |
| 19 | |
| 20 | protected params(): ParameterDef[] { |
| 21 | return [ |
| 22 | { |
| 23 | key: "kernel", |
| 24 | label: "Kernel", |
| 25 | options: [ |
| 26 | { value: "blur", label: "Box blur" }, |
| 27 | { value: "sharpen", label: "Sharpen" }, |
| 28 | { value: "edge", label: "Edge detect" }, |
| 29 | { value: "emboss", label: "Emboss" }, |
| 30 | ], |
| 31 | defaultValue: "edge", |
| 32 | }, |
| 33 | { |
| 34 | key: "image", |
| 35 | label: "Source image", |
| 36 | options: [ |
| 37 | { value: "shapes", label: "Shapes" }, |
| 38 | { value: "gradient", label: "Gradient" }, |
| 39 | ], |
| 40 | defaultValue: "shapes", |
| 41 | }, |
| 42 | ]; |
| 43 | } |
| 44 | |
| 45 | private kernel(): number[] { |
| 46 | const name = this.str("kernel"); |
| 47 | if (name === "blur") return [1, 1, 1, 1, 1, 1, 1, 1, 1].map((v) => v / 9); |
| 48 | if (name === "sharpen") return [0, -1, 0, -1, 5, -1, 0, -1, 0]; |
| 49 | if (name === "emboss") return [-2, -1, 0, -1, 1, 1, 0, 1, 2]; |
| 50 | return [0, 1, 0, 1, -4, 1, 0, 1, 0]; |
| 51 | } |
| 52 | |
| 53 | protected onReset(): void { |
| 54 | const pattern = this.str("image"); |
| 55 | for (let y = 0; y < SIZE; y++) { |
| 56 | for (let x = 0; x < SIZE; x++) { |
| 57 | const u = x / SIZE; |
| 58 | const v = y / SIZE; |
| 59 | let value: number; |
| 60 | if (pattern === "gradient") { |
| 61 | value = u * 0.7 + 0.1; |
| 62 | } else { |
| 63 | const disk = Math.hypot(u - 0.5, v - 0.5) < 0.25 ? 0.5 : 0; |
| 64 | const band = Math.abs(u - v) < 0.06 ? 0.35 : 0; |
| 65 | value = Math.min(1, 0.18 + disk + band + v * 0.2); |
| 66 | } |
| 67 | this.original[y * SIZE + x] = value; |
| 68 | } |
| 69 | } |
| 70 | this.needsCompute = true; |
| 71 | } |
| 72 | |
| 73 | protected onParameterChange(): void { |
| 74 | this.reset(); |
| 75 | } |
| 76 | |
| 77 | protected onUpdate(): void { |
| 78 | if (this.needsCompute) this.compute(); |
| 79 | } |
| 80 | |
| 81 | private compute(): void { |
| 82 | const k = this.kernel(); |
| 83 | // Kernel flip for true convolution (k here is symmetric or treated as correlation). |
| 84 | for (let y = 0; y < SIZE; y++) { |
| 85 | for (let x = 0; x < SIZE; x++) { |
| 86 | let sum = 0; |
| 87 | for (let ky = -1; ky <= 1; ky++) { |
| 88 | for (let kx = -1; kx <= 1; kx++) { |
| 89 | const sy = Math.min(SIZE - 1, Math.max(0, y + ky)); |
| 90 | const sx = Math.min(SIZE - 1, Math.max(0, x + kx)); |
| 91 | sum += this.original[sy * SIZE + sx] * k[(ky + 1) * 3 + (kx + 1)]; |
| 92 | } |
| 93 | } |
| 94 | this.filtered[y * SIZE + x] = Math.min(1, Math.max(0, sum)); |
| 95 | } |
| 96 | } |
| 97 | this.needsCompute = false; |
| 98 | } |
| 99 | |
| 100 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 101 | ctx.fillStyle = theme.bg; |
| 102 | ctx.fillRect(0, 0, this.width, this.height); |
| 103 | if (this.needsCompute) this.compute(); |
| 104 | if (!this.image || this.image.width !== SIZE) { |
| 105 | this.image = new ImageData(SIZE, SIZE); |
| 106 | this.scratch = document.createElement("canvas"); |
| 107 | this.scratch.width = SIZE; |
| 108 | this.scratch.height = SIZE; |
| 109 | } |
| 110 | const data = this.image.data; |
| 111 | for (let y = 0; y < SIZE; y++) { |
| 112 | for (let x = 0; x < SIZE; x++) { |
| 113 | const i = y * SIZE + x; |
| 114 | const v = x < SIZE / 2 ? Math.round(this.original[i] * 255) : Math.round(this.filtered[i] * 255); |
| 115 | const p = i * 4; |
| 116 | data[p] = v; |
| 117 | data[p + 1] = v; |
| 118 | data[p + 2] = v; |
| 119 | data[p + 3] = 255; |
| 120 | } |
| 121 | } |
| 122 | const offCtx = this.scratch!.getContext("2d")!; |
| 123 | offCtx.putImageData(this.image, 0, 0); |
| 124 | ctx.imageSmoothingEnabled = false; |
| 125 | ctx.drawImage(this.scratch!, 0, 0, SIZE / 2, SIZE, 0, 0, this.width / 2, this.height); |
| 126 | ctx.drawImage(this.scratch!, SIZE / 2, 0, SIZE / 2, SIZE, this.width / 2, 0, this.width / 2, this.height); |
| 127 | ctx.strokeStyle = theme.fgSecondary; |
| 128 | ctx.beginPath(); |
| 129 | ctx.moveTo(this.width / 2, 0); |
| 130 | ctx.lineTo(this.width / 2, this.height); |
| 131 | ctx.stroke(); |
| 132 | ctx.font = "11px ui-monospace, monospace"; |
| 133 | ctx.fillStyle = theme.fgTertiary; |
| 134 | ctx.fillText("ORIGINAL", 10, 16); |
| 135 | ctx.fillStyle = theme.accent; |
| 136 | ctx.fillText(this.str("kernel").toUpperCase(), this.width / 2 + 10, 16); |
| 137 | } |
| 138 | |
| 139 | getMetrics() { |
| 140 | let energy = 0; |
| 141 | for (let i = 0; i < this.filtered.length; i++) energy += this.filtered[i]; |
| 142 | return { |
| 143 | kernel: this.str("kernel"), |
| 144 | outputMean: (energy / this.filtered.length).toFixed(4), |
| 145 | size: `${SIZE} x ${SIZE}`, |
| 146 | taps: 9, |
| 147 | }; |
| 148 | } |
| 149 | |
| 150 | describe(): string { |
| 151 | return `A 3x3 ${this.str("kernel")} kernel convolved over the image - swap the kernel and the same nine numbers blur, sharpen, or find edges.`; |
| 152 | } |
| 153 | |
| 154 | entities(): number { |
| 155 | return SIZE * SIZE; |
| 156 | } |
| 157 | } |
| 158 | |
Performance
- Time per tick
- O(pixels * 9) - nine multiplies per pixel
- Space
- Two pixel buffers
- Notes
- The Fourier-transform page explains WHY kernels act as frequency filters - convolution in space is multiplication in frequency.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.