# Hue, Saturation, Value

*Task 2 of 5 · [Colour Spaces](https://gpu.rocks/learn/colour-spaces-8d79c6af.md) · GPU.js Learn*

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

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

## Figures

- **grey lives in the hole in the middle, where none of the three formulas apply**

## Goal

**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

- `hue`: return `-1` when the chroma is `0`, and otherwise the angle in degrees
- The red wedge is the one that can come out negative — add `360` to bring it back onto the wheel
- `saturation`: return `(max − min) / max`, and `0` when `max` is `0` rather than dividing by it
- Leave `paintHue` alone — it renders your hue channel at full strength so you can see it

## 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:

```js
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:

```js
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.

## Starter code

```js
// One thread per pixel. Three quantities, two kernels, one branchy angle.
const gpu = new GPU({ mode });

const hue = gpu.createKernel(function (photo) {
  const pixel = photo[this.thread.y][this.thread.x];
  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;
  // TODO: -1 when there is no chroma at all; otherwise the angle in degrees.
  // Which channel equals v picks the wedge.
  return 0;
}, { output: [64, 64] });

const saturation = gpu.createKernel(function (photo) {
  const pixel = photo[this.thread.y][this.thread.x];
  const v = Math.max(pixel[0], Math.max(pixel[1], pixel[2]));
  const m = Math.min(pixel[0], Math.min(pixel[1], pixel[2]));
  // TODO: (v - m) / v, but guard the black pixel where v is 0.
  return v - m;
}, { output: [64, 64] });

// Already written: paints your hue channel at full saturation and value, so
// the wheel is all you see. Pixels with no hue come out flat grey.
const paintHue = gpu.createKernel(function (h) {
  const angle = h[this.thread.y][this.thread.x];
  let r = 0.24;
  let g = 0.24;
  let b = 0.28;
  if (angle >= 0 && angle < 60) { r = 1; g = angle / 60; b = 0; }
  else if (angle >= 60 && angle < 120) { r = (120 - angle) / 60; g = 1; b = 0; }
  else if (angle >= 120 && angle < 180) { r = 0; g = 1; b = (angle - 120) / 60; }
  else if (angle >= 180 && angle < 240) { r = 0; g = (240 - angle) / 60; b = 1; }
  else if (angle >= 240 && angle < 300) { r = (angle - 240) / 60; g = 0; b = 1; }
  else if (angle >= 300) { r = 1; g = 0; b = (360 - angle) / 60; }
  this.color(r, g, b, 1);
}, { output: [64, 64], graphical: true });

const hues = await hue(chart);
const sats = await saturation(chart);
console.log('top-left swatch is pure red:  hue', hues[0][0], ' saturation', sats[0][0]);
console.log('the grey row has no hue:      hue', hues[28][4], ' saturation', sats[28][4]);

await paintHue(hues);
render(paintHue.canvas);
```

---

Interactive version: https://gpu.rocks/learn/colour-spaces-8d79c6af/2

[Previous task](https://gpu.rocks/learn/colour-spaces-8d79c6af/1.md) · [Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/3.md)
