# Select by Colour

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

Here is the whole module's argument, in one exercise. You want every pixel of the red
ball in `frame` — all of it, top to bottom, shadow included.

In RGB that is a threshold on numbers that move when the light moves. "Red" comes out as
something like `r > 0.5 and r − g > 0.3`, and it works beautifully on the lit
top of the ball. Turn the light down and every one of those numbers falls with it, until the
test stops being true — for a pixel that is exactly as red as it ever was. That kernel is
written for you below, so you can watch it happen.

In HSV, brightness lives in V and nowhere else. Multiply a pixel's r, g and b by the same
factor and H and S do not move at all. So the test becomes "hue near red, saturated enough",
and the shadow costs you nothing. One wrinkle, and it is task 3's wrinkle: red sits at 0°, so
a 15° wedge around it runs from 345° up over the seam to 15°. `h > 345 && h
< 15` is true for no angle whatsoever. Measure the distance *round the wheel*
instead.

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

- **turn the light down and RGB loses the colour; HSV only loses the V**

## Goal

**Goal:** finish `hsvMask` — `1` for pixels within
`this.constants.tol` degrees of `this.constants.target` on the wheel
and at least `this.constants.minSat` saturated, `0` for everything
else — and log how many pixels each mask found.

## Requirements

- Fold `h − target` into `−180 … 180` before comparing — the wedge straddles 0°
- Require `s >= this.constants.minSat`: a pixel with no hue reports `-1`, which is one degree from red, and the saturation floor is what keeps it out
- Return exactly `1` or `0`, nothing in between
- `console.log` both mask counts — the HSV one should find the whole ball, the RGB one only its lit half

## Hint 1 — distance round the wheel

Exactly the fold from task 3, then drop the sign:

```js
let d = h - this.constants.target;
if (d > 180) { d = d - 360; }
if (d < -180) { d = d + 360; }
if (d < 0) { d = -d; }
```

Now `d` is a distance in degrees, 0 … 180, and it does not care where the
seam is.

## Hint 2 — the test itself

Two conditions, and both matter:

```js
if (d <= this.constants.tol && s >= this.constants.minSat) {
  return 1;
}
return 0;
```

## Hint 3 — reading the counts

Total each mask with a plain nested loop in JavaScript after the kernels have
run. The HSV count should be comfortably the larger — and the gap between them is the
part of the ball that RGB gave up on because a lamp was dimmer there.

## Same idea elsewhere

This is chroma keying, and the reason a green screen is *green*: it is the
channel a sensor samples most finely, and it is nowhere near skin. Real compositors go
further into spaces built for exactly this — YCbCr, or CIE L*a*b* — where the two
chromaticity axes are perpendicular to lightness by construction, so a key becomes a
distance in a plane rather than a wedge with a seam in it. On the GPU it stays what you just
wrote: one thread per pixel, no communication, and the mask is a texture the next pass
reads.

## Starter code

```js
// The same intent — "that's red" — expressed in two colour spaces.
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;
  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);
}, { output: [64, 64] });

// "Red" in RGB: bright, and much redder than it is green or blue.
const rgbMask = gpu.createKernel(function (photo) {
  const pixel = photo[this.thread.y][this.thread.x];
  if (pixel[0] > 0.5 && pixel[0] - pixel[1] > 0.3 && pixel[0] - pixel[2] > 0.3) {
    return 1;
  }
  return 0;
}, { output: [64, 64] });

const hsvMask = gpu.createKernel(function (photo, hueMap) {
  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]));
  let s = 0;
  if (v > 0) { s = (v - m) / v; }
  const h = hueMap[this.thread.y][this.thread.x];

  // TODO: 1 when h is within this.constants.tol degrees of
  // this.constants.target ON THE WHEEL, and s clears this.constants.minSat.
  // The red wedge runs from 345 up over the seam to 15 ... doesn't it?
  if (h > 345 && h < 15) {
    return 1;
  }
  return 0;
}, {
  output: [64, 64],
  constants: { target: 0, tol: 15, minSat: 0.35 },
});

const hues = await hue(frame);
const inRgb = await rgbMask(frame);
const inHsv = await hsvMask(frame, hues);

// TODO: total both masks and log the two counts.
```

---

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

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