Task 2 of 5
RGB says how much of each light to mix. It does not say what colour something is. HSV does, by splitting the question three ways: hue (which colour), saturation (how far from grey), value (how bright). Two of the three take one line each. The third is where the interesting code lives.
Start from the largest and smallest channel. V = max. The gap between them is
the chroma, C = max − min — how far this pixel is from grey —
and saturation is that gap as a fraction of the value, C / V.
Hue is an angle: red at 0°, green at 120°, blue at 240°, round to red again at
360°. Which channel is the max picks a 60° wedge of that wheel, and the other two channels
say where you sit inside it. When the chroma is zero there is no wedge at all: a grey pixel
has no hue. Not "hue 0" — 0 is red. No hue, and it needs a value that is not an angle, which
here is -1.
V = max(r, g, b)
C = V - min(r, g, b)
S = C / V
V is r H = 60 * ((g - b) / C)
+ 360 when that comes out negative
V is g H = 60 * ((b - r) / C + 2)
V is b H = 60 * ((r - g) / C + 4)
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].
hue, in degrees, with
-1 where there is no hue, and saturation. The graphical kernel
below is already written and will paint whatever hue channel you produce.hue: return -1 when the chroma is 0, and otherwise the angle in degrees360 to bring it back onto the wheelsaturation: return (max − min) / max, and 0 when max is 0 rather than dividing by itpaintHue alone — it renders your hue channel at full strength so you can see itEvery branch below is written in terms of the same three numbers, so name them once at the top of the kernel:
const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2]));
const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2]));
const c = v - m;v is one of the three channels exactly — Math.max
returns one of its arguments, it does not compute a new number — so you can compare
against it directly:
if (c === 0) {
return -1;
}
if (v === pixel[0]) {
const h = 60 * ((pixel[1] - pixel[2]) / c);
if (h < 0) { return h + 360; }
return h;
}
if (v === pixel[1]) {
return 60 * ((pixel[2] - pixel[0]) / c + 2);
}
return 60 * ((pixel[0] - pixel[1]) / c + 4);Both kernels have a divide-by-nothing case and they are not the same case.
Hue divides by the chroma, which is zero for any grey. Saturation divides by
the value, which is zero only for black. Test before you divide in both, or
those pixels come back NaN and quietly poison everything downstream.
cvtColor(src, dst, COLOR_BGR2HSV), and on a GPU it is exactly
what you just wrote: per-pixel, no communication, embarrassingly parallel. Watch the shape
of it, though — the wedge is chosen by a branch, and threads in the same warp (CUDA) or
subgroup (WebGPU/Metal) execute in lockstep, so a tile containing several wedges pays for
every branch it contains rather than just its own. Branch divergence is the cost
model here, and it is why production colour-conversion shaders are often written
branch-free with step() and mix() instead.
This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.