Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import type { ParameterDef, ThemeColors } from "../core/types";
3
4const 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 */
11export 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.