Task 1 of 5

Three Greys, One Pixel

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.

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

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:

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:

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.

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.