Task 1 of 6
Canny's first move looks like vandalism: before you go looking for edges, you throw detail away. The reason is that every later stage is built on a derivative, and the derivative of noise is enormous. A pixel that wobbles by ±0.12 against its neighbours has no visible brightness to speak of — but a difference operator reads that wobble at full strength, because a difference is exactly what it is looking for.
The numbers on this task's own picture: run the rest of this module on gray
unsmoothed and you get 596 edge pixels, 154 of them in flat background —
pure noise, promoted to structure. Smooth it first and the same pipeline reports
299 edge pixels and not one spurious. That is what the blur buys.
Convolution & Filters already taught the sliding window, the box blur, clamped edges, and the fact that a box blur is separable. Both facts come due here. A Gaussian beats a box for this job because it has no corners: a box filter's response oscillates as the window slides, so it manufactures small ridges of its own — precisely the thing stage 3 is about to hunt for. And a Gaussian is separable too, so a 5×5 window is two 5-tap passes, not one 25-tap pass: 10 reads per pixel instead of 25.
Image data comes in row-major: image[y][x] is the pixel in row y,
column x, and each pixel is an [r, g, b, a] array with channels from
0 to 1. Mind the inversion that catches everyone — sizes are given width-first
(output: [width, height]), but indexing runs row-first, so this thread's own
pixel is image[this.thread.y][this.thread.x]. Swap those two and you read the
transpose of your image. Three-dimensional data follows the same rule:
output: [w, h, d] is indexed [z][y][x].
blurX is
written for you; write blurY so the pair applies the weights
[1, 4, 6, 4, 1] / 16 along each axis, indexes clamped at the edges.1, 4, 6, 4, 1 and divide the total by 16blurY walks rows — offset this.thread.y, not this.thread.x0…this.constants.lastblurX holds y still and moves x.
blurY does the mirror image: hold x still, move
y. Copying the body is fine — copying its axis is the mistake
the tests are watching for.
let y0 = y - 2;
if (y0 < 0) y0 = 0;
let y4 = y + 2;
if (y4 > this.constants.last) y4 = this.constants.last;
— and the same for y1 and y3 at distance 1.
return (map[y0][x] + 4 * map[y1][x] + 6 * map[y][x]
+ 4 * map[y3][x] + map[y4][x]) / 16;
The weights sum to 16, so the divide is what keeps a flat area flat.
MPSImageGaussianBlur and NVIDIA NPP's
nppiFilterGaussBorder both decompose internally; a WebGPU post-processing
chain does horizontal-then-vertical into a ping-pong pair of textures. The saving grows
with the kernel: a 15×15 Gaussian is 225 taps as one pass and 30 as two.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.