Task 5 of 5

Payoff: An Image's Tone Histogram

The payoff, and the histogram everybody has actually seen: an image's tone histogram — how many pixels are dark, how many mid, how many bright. Every photo editor draws one, because it tells you a shot is underexposed before your eyes do.

Two kernels, and the reason for two is worth a sentence. Luminance is a per-pixel calculation and there are 4,096 pixels — but there are 32 bins, so a single histogram kernel would recompute every pixel's luminance 32 times over, once per bin thread. Compute it once into a 64 × 64 map, then histogram the map. Map first, bin second; the map pass is 4,096 luminance evaluations instead of 131,072.

Luminance runs 0 … 1, so 32 bins over that range is a bin every 0.03125 — the same clamped floor as task 3, with lo = 0 and span = 1 doing nothing visible. And the same smoke alarm: 4,096 pixels in, 4,096 counted out.

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 luminance map of photo, histogram it into 32 tone bins, and log the total.

Requirements

Hint 1 — the map pass

Straight out of any grayscale kernel — read this thread's pixel and return a number instead of painting it:

const pixel = photo[this.thread.y][this.thread.x];
return 0.299 * pixel[0] + 0.587 * pixel[1] + 0.114 * pixel[2];
Hint 2 — scanning a 2D map from a 1D kernel

The histogram kernel has 32 threads and a 64 × 64 map, so each thread runs two nested loops over the map. Both bounds are constants, which is what the WebGL backend needs:

for (let y = 0; y < this.constants.size; y++) {
  for (let x = 0; x < this.constants.size; x++) {
    const bin = Math.min(
      this.constants.bins - 1,
      Math.floor(map[y][x] * this.constants.bins)
    );
    if (bin === this.thread.x) count++;
  }
}
Hint 3 — read the shape of the answer

Once it runs, look at the counts: the first bins and the last bins are empty. This image never gets truly black or truly white — which is precisely the thing a tone histogram exists to tell you.

Same idea elsewhere

Tone histograms are load-bearing infrastructure, not a readout: auto-exposure, auto-contrast and histogram equalization all start here, and phone ISPs compute one in fixed function hardware on every frame. The two-pass shape generalizes past images — derive the quantity once into a buffer, then bin the buffer — and it is the same reason CUDA and WebGPU pipelines materialize an intermediate rather than recomputing inside an inner loop. Turning these counts into a cumulative curve (the next step of equalization) is a prefix sum, which is the one parallel primitive this module does not need.

All tasks in Histograms & Binning

  1. The Increment That Vanishes
  2. One Thread Per Bin
  3. Where Does 7.35 Go?
  4. Partial Histograms, Then Merge
  5. Payoff: An Image's Tone Histogram

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