← Notes

Notes

Interactive

Colour Science Playground

A pixel is not a colour. It is three numbers, and turning them back into a colour takes a stack of conventions — a transfer function, a set of primaries, a white point, an observer — every one of which can be got wrong independently. Most colour bugs are not maths errors. They are two pieces of code disagreeing about which of those conventions is in force.

This page is nine instruments on that stack. Every panel runs on your machine, on either the built-in test images or one of your own — drag a photo onto any picture below and the whole page will use it. Nothing is uploaded anywhere.

Colour bench

Three numbers, and everything you must agree on first

09 bays
live · client-side
v the pixel you actually store — three encoded numbers
OETF the transfer function: light in, code values out
M⁻¹ the primaries — which real lights your three channels mean
A chromatic adaptation: the white point you are assuming
X CIE XYZ — the colour itself, before any encoding choices
[ 01 ]

One colour, every space at once

live

Move the cursor over the picture. Every panel below describes the same colour — these are not different colours, they are different coordinate systems on one point. Note how far sRGB and Linear RGB diverge, and that HSV and HSL are computed from the encoded values, which is why their "value" and "lightness" have almost nothing to do with how bright a colour looks.

drop an image here

Sample

hex
pixel
chromaticity x, y
relative luminance Y

Imageor drop your own

hover the image to sample · the loupe shows a 9×9 neighbourhoodall conversions D65, 2° observer

  • Hover the neutral row of the chart. a* and b* sit near zero, and chroma C* collapses — that is what "neutral" means numerically.
  • Compare the white and black patches. L* spans nearly 0–100 while linear Y spans 0–1 very unevenly: L* is built to be perceptually uniform, Y is not.
  • Find a saturated blue. Its HSV value reads high while its luminance Y is tiny — blue simply carries very little light.
Takeaway There is no such thing as "the RGB value" of a colour without a colour space attached. The same triple (0.5, 0.5, 0.5) is a different physical colour in sRGB, in Display P3, and in linear light. XYZ is the only row above that is device-independent — everything else is a convention layered on top of it.
[ 02 ]

What "50% grey" actually means

live

Encoded values are not proportional to light. sRGB spends more code values on the shadows, where the eye is sensitive, and fewer on the highlights, where it is not. The consequence is on the right: a fine black-and-white checkerboard emits half the light of white — but the sRGB value that emits half the light is 188, not 128. Drag step back to average the checker in light, the way your eye does, and watch which patch it matches.

transfer function
checker · 128 · 188

Compare against

pure power law γ

sRGB is not a pure power law. It has a short linear segment near black — a plain 2.4 power has infinite slope at zero, which quantises badly and amplifies sensor noise in the shadows.

Checker

step back average in light
0.5 encoded → light
half the light → sRGB
  • Set step back to 100%. The checker lands on the right-hand patch (188), not the middle one (128). If you have ever wondered why resized images look too dark, this is why.
  • Sweep γ. No single power law matches sRGB everywhere — near black the curves separate sharply.
Takeaway Anything that averages pixels — resizing, blurring, antialiasing, alpha compositing, mipmapping — is physically wrong unless it happens in linear light. The error is largest exactly where contrast is highest, which is why naively downscaled images look muddy and thin lines disappear.
[ 03 ]

An image is a cloud of points

live

Every pixel plotted in colour space, painted its own colour. Drag to orbit. The same cloud looks completely different depending on the axes you choose — and that shape is exactly what makes some operations easy and others hard. Segmentation that is hopeless in RGB is often trivial in Lab, because Lab pulls lightness out into its own axis.

drag to orbit

Axes

RGB is the unit cube — neutrals run along its diagonal, which is why "remove the grey" is an awkward operation there. Lab stands lightness up as its own axis with colour spread around it. HSV is a cylinder: hue around, saturation outward.

Imageor drop your own

points plotted
  • Load Chart and switch to Lab. The neutral patches stack into a vertical column at the centre — the achromatic axis — while the colour patches fan out around it.
  • Load Hazy in RGB. The cloud collapses toward the diagonal and occupies a tiny corner of the cube: that cramped shape is low contrast, and it is what bays 06 and 07 attack.
Takeaway Real images occupy a startlingly small part of any colour space. That is why compression works, why white balance can be estimated from statistics alone, and why the gamut of a display matters far less than its contrast for most photographs.
[ 04 ]

White balance is not a colour cast

live

Setting a colour temperature declares what the light was, and the image is re-rendered as it would have looked under D65. Doing that properly means adapting in a cone-response space, not scaling R, G and B. Switch the method to channel gain and watch the saturated patches drift while the neutrals stay put — that gap is why chromatic adaptation transforms exist.

balanced to D65
chromaticity

Illuminant

temperature
tint green ↔ magenta

Adaptation

assumed illuminant
white point x, y
locus in use

Imageor drop your own

Planckian locus (blackbody) daylight locus (D-series) sRGB primaries current white point
  • Set the temperature to 6504 K and look at the two loci. They do not meet — D65 sits on the green curve, above the blackbody one. "D65" and "6504 K" are not synonyms, and the readout tells you which curve is in use.
  • Drag down to 2800 K (tungsten). Everything goes blue, because you have just told the renderer the light was orange and it removed it.
  • Switch between Bradford and channel gain at an extreme temperature. Neutrals agree; saturated colours do not.
Takeaway Cameras do not record colour, they record colour under an illuminant. Every raw file needs a white point guessed before it means anything, and "auto white balance" is that guess. It is why two cameras pointed at the same scene disagree, and why a grey card is worth more than a better sensor.
[ 05 ]

What happens to colours you cannot show

live

Push chroma past what sRGB can reproduce and something must give. Clip slams each channel into range independently, which changes the ratios between them — so the hue shifts and detail in the saturated region flattens to a solid blob. Compress holds hue and lightness fixed and walks chroma down until the colour fits. Mask just shows you the damage.

rendered
a*b* slice · sRGB boundary

Push

chroma boost

Handling

Slice

lightness L*
pixels out of gamut

Imageor drop your own

  • Sweep L* on the slice. The sRGB region is a lopsided blob that changes shape with lightness — there is far more room for saturated yellow at high L* than for saturated blue.
  • Set boost to 2× and flip between Clip and Compress. Clipping drags saturated blues toward purple; compression keeps the hue and gives up saturation instead.
  • Use Mask to see which patches fail: the saturated blues, reds and magentas go first, while the neutral row never leaves the gamut at any boost.
  • Load Vivid and nudge the boost off 1.0. It jumps straight to 100% — that image was generated on the gamut boundary, so there is no headroom anywhere in it.
Takeaway Clipping is not a neutral operation. Because it treats the three channels independently, it moves colours sideways in hue, not just inward in saturation — and it does so unevenly, so smooth gradients turn into flat patches with hard edges. Any pipeline that ends in clamp(0,1) is making this decision whether or not it means to.
[ 06 ]

Flattening the histogram

live

Equalisation remaps tones so that the cumulative distribution becomes a straight line — every output level gets roughly the same number of pixels. The mapping curve is the CDF, drawn in amber on the right. It runs on L* only, leaving a* and b* alone, so the colours stay put and only the tones move.

equalised
histogram & CDF

Amount

strength

At 0% you see the original. Drag to 100% and watch the haze lift — then look at what it does to the sky, where a few hundred nearly-identical values get stretched across a wide range.

Imageor drop your own

input histogram after equalisation CDF — the mapping curve
  • The input histogram is a narrow spike: that is the haze. Equalisation spreads it, and the CDF's steepest section shows where it is spending the most output range.
  • Watch the sky at 100%. Wherever the CDF is steep, tiny input differences become large output ones — and sensor noise gets the same amplification as real detail.
Takeaway Global equalisation optimises a statistic, not a picture. It has no idea that the sky and the foreground are different things, so it must pick one mapping that serves both — and it usually serves neither. That single observation is what the next bay is for.
[ 07 ]

Equalisation that knows where it is

live

CLAHE equalises each tile separately, so the sky and the foreground each get their own mapping. Two details make it usable: the per-tile histogram is clipped before equalising, which caps how much flat regions can be amplified, and the four nearest tile mappings are blended bilinearly, without which you would see the tile grid as hard seams.

global equalisation
CLAHE

Tiling

tiles

Contrast limit

clip limit

Expressed as a multiple of the average bin count, so it means the same thing at any tile size. At exactly 1.0 the histogram is clipped flat, the mapping becomes the identity, and CLAHE does nothing at all.

tiles
clip limit

Imageor drop your own

  • Set clip limit to 1.0. The right panel becomes the original — proof that the clip step is doing all the work of restraining the effect.
  • Push it to 20 with 16 tiles. Every patch of noise becomes texture, and the halos around the ridgelines appear. That is the failure mode CLAHE exists to avoid.
  • Turn on the tile lattice and set tiles to 2. You can see the mapping change across the image, yet there is still no seam — that is the bilinear blend.
Takeaway CLAHE is two hyper-parameters trading against each other: more tiles means more locality but less data per histogram, and a higher clip limit means more contrast but more amplified noise. There is no universally right setting — which is exactly why it is exposed as a knob in every medical and satellite imaging pipeline rather than being applied automatically.
[ 08 ]

Borrowing another image's palette

live

Reinhard's colour transfer is almost embarrassingly simple: convert both images to a decorrelated space, then shift and scale each channel of the source so its mean and standard deviation match the target's. Six numbers per image. No correspondence, no matching, no optimisation — and it works well enough that it is still a baseline twenty-five years on.

Sourcerecoloured

Targetpalette source

Amount

strength
source mean L/a/b
source σ L/a/b
target mean L/a/b
target σ L/a/b

transfer runs in CIELab · Reinhard et al. used lαβ, a log-LMS variantdrop your own images into either panel

  • Send Cool to Warm and back. The a* and b* means are what carry the colour cast; L* carries the exposure.
  • Try transferring from the Chart. It fails badly — its statistics describe a grid of flat patches, nothing like a photograph. The method assumes both images have broadly similar structure.
  • Drop in two of your own photos. It works best when they are the same kind of scene.
Takeaway The whole method rests on the channels being roughly independent, so that moving each one separately does not tear the image apart. That is the entire reason for working in Lab or lαβ rather than RGB — in RGB the channels are strongly correlated, and matching their moments independently produces exactly the garish mess you would expect.
[ 09 ]

What a dichromat actually sees

live

Ordinary colour vision samples the spectrum with three cone types, giving a 3D colour space. Dichromats have two, so theirs is two-dimensional — and simulation is a projection onto that plane. Any two colours that land on the same point afterwards are not merely similar to them; they are the same colour, with no way to tell them apart.

as sent
as seen

Type

severity

Below 100% this models an anomalous trichromat — a shifted cone rather than a missing one, which is far more common than full dichromacy.

Correction

Takes the error the observer cannot see and folds it into directions they can. The left panel shows the re-coded image; the right shows how that observer sees it.

condition
prevalence
ΔE between pure red & green

Imageor drop your own

  • On the Dots card, switch between None and Deutan. The shape that was obvious becomes invisible — the two colours were chosen to differ almost entirely along the axis a deuteranope has lost.
  • Now tick daltonise. The shape comes back on the right, at the cost of a left panel that looks wrong to everyone else. That trade is the whole design problem.
  • Watch ΔE between red and green as you drag severity. It falls toward zero — a number that says plainly why red/green status indicators are a bad idea.
Takeaway Roughly one man in twelve has some form of red-green deficiency. Nothing above requires them to see differently — it requires the encoding to carry information along more than one axis. Colour plus shape, or colour plus position, survives every simulation on this page. Colour alone does not.

— end of bench —

What is actually running here

color-sci.js holds the colorimetry: transfer functions, the XYZ and Lab conversions, chromatic adaptation, the Planckian and daylight loci, gamut compression, the Viénot cone-projection matrices for dichromacy, CLAHE, and Reinhard transfer. It has no DOM dependency, and its test suite runs 89 assertions against published reference values — sRGB primaries in Lab, D65 chromaticity, round-trips through every space, and the invariants that catch the errors which otherwise look plausible.

Two of those are worth naming, because both produced perfectly reasonable-looking output while being wrong. The Viénot projection coefficients are defined against one specific LMS scaling; pair them with a differently normalised matrix and greys come out lurid, which is why there is now an assertion that neutrals survive simulation. And “D65” is not “6504 K” — daylight is not a blackbody, so the D-series illuminants sit slightly off the Planckian locus. Bay 04 draws both curves rather than pretending they coincide.

Everything runs on your machine. Images you drop in are decoded in this tab and never leave it.

For the underlying theory, Foundations of Computer Vision (Torralba, Isola and Freeman) is the reference I would point at first. This page is a companion to the Camera Geometry Playground, which does the same thing for projection rather than colour.