Task 3 of 6
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.
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].
mag[y][x] when it is at least as large as
both of its neighbours along the quantised gradient direction, and return 0
otherwise.+ Math.PI before quantising — 180° and 0° are the same axis(ax, ay) step of (1,0), (1,1), (0,1) or (-1,1)mag[y + ay][x + ax] and mag[y - ay][x - ax] — the GRADIENT axis, not the edge0, borders includedTake 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.
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; }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.
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.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.