Task 3 of 6

Non-Maximum Suppression

Stage 2 leaves edges several pixels thick: a gradient does not switch on at one column, it ramps across the whole slope. Canny's third stage is what makes the output an edge map rather than a heat map — and it is the stage everyone gets wrong.

The rule: a pixel survives only if it is a local maximum along its own gradient direction. Not along the edge — across it. The gradient points the way the brightness climbs, which is perpendicular to the edge itself, and walking one step each way along that direction is walking off the ridge on both sides. If the pixel is the top of that little ridge, it stays; if either neighbour is above it, it is on the slope, and it goes to zero.

Two steps, then. Quantise the angle to one of four axes — the only neighbours you have are the eight around you, so the gradient's direction can only be answered to 45° — and then compare against the two neighbours on that axis. Quantising has one trap in it: atan2 returns −π…π, but an axis has no sense of forwards. −45° and +135° are the same axis, so an angle below zero has to be wrapped up by 180° first. Skip the wrap and one of your four buckets is never selected at all — on this task's own map, that is 570 of the 1,049 gradient pixels quietly landing in the wrong bucket.

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

the two neighbours that matter are across the edge, not along it — A patch of gradient magnitudes holding a three-pixel-wide ridge. The pixel under test is compared with the two neighbours lying along its gradient direction, which crosses the ridge; the edge itself runs at right angles to that. On the right, the three-wide band becomes a one-pixel line.
Goal: keep mag[y][x] when it is at least as large as both of its neighbours along the quantised gradient direction, and return 0 otherwise.

Requirements

Hint 1 — which neighbours belong to which bucket

Take the angle to degrees after wrapping, so it lies in 0…180, and read off the axis:

  0° ± 22.5   →  (ax, ay) = ( 1, 0)   left  ↔ right
 45° ± 22.5   →  (ax, ay) = ( 1, 1)   ↖ ↘
 90° ± 22.5   →  (ax, ay) = ( 0, 1)   up    ↕ down
135° ± 22.5   →  (ax, ay) = (-1, 1)   ↗ ↙

Start with (1, 0) and let the last bucket fall out of the else: 0° and 180° share it.

Hint 2 — the shape of the body
let a = dir[y][x];
if (a < 0) a += Math.PI;
const deg = a * 180 / Math.PI;
let ax = 1;
let ay = 0;
if (deg >= 22.5 && deg < 67.5) { ax = 1; ay = 1; }
else if (deg >= 67.5 && deg < 112.5) { ax = 0; ay = 1; }
else if (deg >= 112.5 && deg < 157.5) { ax = -1; ay = 1; }
Hint 3 — the comparison
const m = mag[y][x];
if (m >= mag[y + ay][x + ax] && m >= mag[y - ay][x - ax]) {
  return m;
}
return 0;

>=, not >: on a perfectly symmetric edge the two middle pixels tie, and > would erase both and leave a hole where the edge was. The border check has already returned, so these indexes are in bounds.

Same idea elsewhere

The name is borrowed all over vision: object detectors run "NMS" over overlapping boxes with exactly this argument — keep the local maximum, drop everything it explains. On the GPU the pattern is a pure gather, one thread per pixel with no coordination, which is why NVIDIA's VPI, OpenCV's cudaimgproc and every WebGPU implementation fuse it into a single compute pass. The awkward part on real hardware is the branch: four buckets means four different neighbour pairs, and a warp whose threads disagree runs all four paths — which is why some implementations interpolate along the true angle instead of quantising, trading arithmetic for branch uniformity.

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.