Task 2 of 6
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.
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].
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.gx is the right column minus the left, middle row counted double; gy is the bottom row minus the topmagnitude returns Math.sqrt(gx * gx + gy * gy) — the length, not its squaredirection returns Math.atan2(gy, gx) — vertical component first, in radians0 thereSame 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.
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.
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.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.