Task 1 of 6

Blur First: a Separable Gaussian

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.

Array layout in gpu.js

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].

five kernels in a row — you build them left to right, then chain them in task 6 — The five stages of Canny as a strip of pictures: noisy photo, blurred photo, a thick gradient ridge, that ridge thinned to one pixel, the thin line broken into strong and weak segments, and one unbroken edge after hysteresis.
Goal: finish the vertical half of the blur. 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.

Requirements

Hint 1 — the same filter, turned ninety degrees

blurX 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.

Hint 2 — the clamps
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.

Hint 3 — the whole return
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.

Same idea elsewhere

Separability is not a gpu.js trick — it is why production blurs are fast everywhere. Metal Performance Shaders' 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.

All tasks in The Canny Edge Pipeline

  1. Blur First: a Separable Gaussian
  2. Magnitude, and the Angle Nobody Mentions
  3. Non-Maximum Suppression
  4. Strong, Weak, Gone
  5. Hysteresis: Run It Until Nothing Changes
  6. Payoff: Five Stages, Zero Round Trips

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.