Task 2 of 6

Magnitude, and the Angle Nobody Mentions

The Sobel pass you wrote in Convolution & Filters answered one question: how strong is the change here, √(gx² + gy²). Canny needs a second answer from the same eight reads, and it is the one that usually gets skipped: which way does the change point. Math.atan2(gy, gx) — and the next stage is built entirely on it. Get the angle wrong and non-maximum suppression compares the wrong two neighbours, silently, on every pixel.

Two things about atan2 worth saying out loud. It takes the vertical component first: Math.atan2(gy, gx), not the other way round — swap them and every angle is reflected about 45°. And it returns radians in −π…π, which is why the result can be negative: a gradient pointing up-and-right and one pointing down-and-left are 180° apart and describe the same edge. Stage 3 is where that gets sorted out.

gray here is already smoothed — it is what stage 1 hands over. Both kernels read the same 3×3 neighbourhood; the starter has pulled the nine cells into locals for you.

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

Goal: compute gx and gy from the Sobel grids in both kernels, then return the gradient's length from magnitude and its angle in radians from direction.

Requirements

Hint 1 — the two grids

Same pair Convolution & Filters used:

const gx = (tr + 2 * mr + br) - (tl + 2 * ml + bl);
const gy = (bl + 2 * bm + br) - (tl + 2 * tm + tr);

gy is bottom minus top because y runs down the image. Flip that sign and the magnitude will not notice — it squares everything — but every angle will.

Hint 2 — the two returns
return Math.sqrt(gx * gx + gy * gy);   // magnitude
return Math.atan2(gy, gx);             // direction, radians

Leaving the Math.sqrt off is tempting — comparisons on squares sort the same way — but every threshold in the rest of this module is calibrated against a length, and squaring bends the scale.

Same idea elsewhere

atan2 is a hardware instruction's worth of work on every GPU: CUDA has atan2f (and __fdividef for the cheap path), WGSL and Metal both spell it atan2, and gpu.js compiles Math.atan2 straight to GLSL's atan(y, x). OpenCV's cv::Canny famously avoids it altogether — it compares |gy| against tan(22.5°)·|gx| with integer arithmetic — which is the same quantisation you are about to write, with the trigonometry folded away.

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.