# Three Greys, One Pixel

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

Nearly every vision algorithm's first move is to leave RGB, because RGB tangles
together the two things you usually want to reason about separately: what colour something
is, and how bright it is. Brightness is the easier half, and the one most often got wrong.

There are three answers to "how bright is this pixel", and they do not agree. The channel
average `(r + g + b) / 3` is what everyone writes first, and it is simply false —
the eye is roughly five times more sensitive to green than to blue, so a pure green and a
pure blue that "average" the same are nowhere near equally bright. Weighting the channels
fixes that: `0.299r + 0.587g + 0.114b` is Rec. 601 **luma**, the
recipe Data In, Data Out had you write.

But luma weights the numbers *as stored*, and sRGB channels are
**gamma-encoded**: 0.5 in a PNG is not half the light of 1.0, it is about 21% of
it. Relative luminance undoes that encoding first and then weights the actual light. It is
the number a photometer would agree with, and the one every contrast-ratio rule is built
on.

```js
t = (c + 0.055) / 1.055

c <= 0.04045  linear = c / 12.92
otherwise     linear = Math.pow(t, 2.4)

Y = 0.2126*R + 0.7152*G + 0.0722*B
```

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

## Goal

**Goal:** compute a 64 × 64 relative-luminance map of `photo`,
and log that map's value for the one pixel the starter already prints two other ways.

## Requirements

- Keep the kernel numeric — `output: [64, 64]`, one thread per pixel
- Linearise *each* of r, g and b with the sRGB transfer function above, before any weighting
- Weight the linear channels `0.2126 R + 0.7152 G + 0.0722 B`
- `console.log` the relative luminance of `photo[1][35]` beside the two greys already printed

## Hint 1 — one channel at a time

The transfer function is a two-case branch, and it is the same branch three
times over. Pull the channels into `let` variables so you can rewrite them
in place:

```js
let r = pixel[0];
if (r <= 0.04045) {
  r = r / 12.92;
} else {
  r = Math.pow((r + 0.055) / 1.055, 2.4);
}
```

## Hint 2 — then the weights

Once `r`, `g` and `b` hold linear light, the
last line is just the weighted sum:

```js
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
```

Note that these are *not* the 0.299 / 0.587 / 0.114 of the earlier module. Those
weights belong to gamma-encoded channels; these belong to linear ones. Mixing the two
pairs up is the classic version of this bug.

## Hint 3 — reading the answer

`map[1][35]` is the pixel the starter prints. Expect all three
numbers to differ, and the linearised one to be much the smallest: most of what looks
like brightness in an sRGB file is the encoding, not the light.

## Same idea elsewhere

Every graphics API knows about this and will do it for you if you ask: an
`rgba8unorm-srgb` texture in WebGPU, `GL_SRGB8_ALPHA8` in OpenGL and
`MTLPixelFormatRGBA8Unorm_sRGB` in Metal all linearise on read and re-encode on
write, in fixed-function hardware, for free. Blending or filtering in gamma space because
you forgot to ask is one of the oldest bugs in rendering — it is why badly-resized images
get darker, and why naive alpha compositing leaves dark fringes.

## Starter code

```js
// One thread per pixel: a pure map, no neighbours involved.
const gpu = new GPU({ mode });

const relativeLuminance = gpu.createKernel(function (photo) {
  const pixel = photo[this.thread.y][this.thread.x];
  // TODO: undo the sRGB gamma encoding on each channel FIRST,
  // then weight the linear channels 0.2126 / 0.7152 / 0.0722.
  return 0.2126 * pixel[0] + 0.7152 * pixel[1] + 0.0722 * pixel[2];
}, { output: [64, 64] });

const map = await relativeLuminance(photo);

// The same pixel, three ways. Two of them are done for you, on the host —
// photo.at(x, y) is the host-side view of photo[y][x].
const p = photo.at(35, 1);
console.log('channel average:   ', (p[0] + p[1] + p[2]) / 3);
console.log('Rec. 601 luma:     ', 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]);
// TODO: log the relative luminance of that same pixel, out of your map.
```

---

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

[Next task](https://gpu.rocks/learn/colour-spaces-8d79c6af/2.md)
