Task 2 of 5

Hue, Saturation, Value

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

grey lives in the hole in the middle, where none of the three formulas apply
Goal: finish the two kernels — 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.

Requirements

Hint 1 — the three quantities first

Every 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;
Hint 2 — which wedge am I in?

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);
Hint 3 — the two zeros

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.

Same idea elsewhere

This is 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.

All tasks in Colour Spaces

  1. Three Greys, One Pixel
  2. Hue, Saturation, Value
  3. The Midpoint of 350° and 10°
  4. Select by Colour
  5. Payoff: What Colour Is This Picture?

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.