How it works

How Halftint works

This page follows one colour through the program: from the number on a tube's chart to a spectrum, through the mixing maths, into a recipe, and out onto the map of what a palette can reach. The diagrams are live. They run the same equations and the same tables as the app, in your browser, simplified only where the caption says so.

The pipeline in one line

Every recipe and every cell of the gamut map is produced by the same loop. Take a set of concentrations, one per paint. Mix the paints' spectra according to those concentrations. Turn the mixed spectrum into a colour number. Measure how far that number is from the target. Nudge the concentrations to reduce the distance. Repeat.

  1. Tube coloursRGB from a chart
  2. Spectrum38 reflectance bands
  3. K/Sabsorb ÷ scatter
  4. Mixweighted by concentration
  5. Spectrumback to reflectance
  6. OKLabvia XYZ
  7. ΔEdistance to the target
  8. Adjustgradient step, repeat

The rest of this page takes those stages one at a time.

1. A tube colour becomes a spectrum

The program only knows each paint as a single sRGB colour, taken from the manufacturer's chart. Mixing has to happen per wavelength, though, because two pigments that look the same can absorb very different parts of the spectrum and mix very differently. So the first step turns one colour into a plausible reflectance curve: how much light the paint reflects at each of 38 wavelengths from 380 to 750 nanometres.

The method comes from the Sochorová and Jamriška paper and the spectral.js library. The colour is linearised, then split into seven weights: how much of it is white, cyan, magenta, yellow, red, green and blue. Each of those seven has a fixed, physically plausible base spectrum. The paint's spectrum is the weighted sum. In the diagram, the thin lines are the seven base spectra scaled by their weights and the heavy line is their sum.

Six tube colours from the standard list, each decomposed into weighted base spectra and summed. The white base carries most of a pale colour; a saturated blue leans on the cyan and blue bases.

Because the input is a chart colour rather than a measured spectrum, this curve is an estimate. Two real paints with the same chart colour would get the same curve. That is the main reason the app labels chart-derived colours as approximate.

2. Reflectance becomes absorption and scattering

Reflectance does not add up when paints are mixed. What does add up, to a good approximation, is the ratio of how strongly a paint film absorbs light to how strongly it scatters it, written K/S. Kubelka and Munk showed in 1931 that for an opaque film the two are related by a simple formula: K/S = (1 − R)² / 2R, where R is the reflectance at that wavelength. A wavelength the paint reflects well has a small K/S. A wavelength it swallows has a large one.

Ultramarine blue. Left, its reflectance curve; right, the same curve converted to K/S. The cursor picks one wavelength on both. Where reflectance is low, K/S climbs steeply.

3. Paints mix in K/S space

To mix, the program takes each paint's K/S curve, multiplies it by that paint's concentration, adds them up wavelength by wavelength, and divides by the total concentration. Then it converts the mixed K/S back to reflectance with the inverse of the formula above. That is the whole mixing model. It is why ultramarine and cadmium yellow give a green: in the middle of the spectrum both paints have a moderate K/S, so the mix still reflects there, while the blue end is absorbed by the yellow and the red end by the blue.

Ultramarine and cadmium yellow mixed at a sliding concentration. Left, the two K/S curves and their weighted average; right, the resulting reflectance. The two swatches compare the pigment mix with what a screen would show if the same two colours were averaged as light.

4. A spectrum becomes a colour number

A spectrum has 38 numbers; a colour difference needs three. The program integrates the reflectance against the CIE 1931 colour matching functions (the sensitivity curves of the eye's three cone types, under daylight) to get XYZ. XYZ is then converted to OKLab, a colour space designed so that equal distances look like equal differences. The recipe's ΔE is the plain straight-line distance between the mix and the target in OKLab.

The mixed reflectance from the previous figure, multiplied by the three colour matching functions and integrated across the spectrum. The bars accumulate X, Y and Z as the sweep crosses the wavelengths; the OKLab coordinates and the final swatch follow.

The app shows OKLab distance directly. A difference of 0.01 is hard to see. Under 0.03 the app calls a mix reachable, and that is the line the wheel draws. Up to 0.08 it calls the match fair. The palette pages on this site multiply the same number by 100, so a 3 there is a 0.03 here.

5. Finding the recipe

A recipe is a set of concentrations that sum to one, with none negative. For three paints that is a triangle: each corner is a single paint, the edges are two-paint mixes, and the interior is every three-paint mix. For six paints it is the same shape in five dimensions. The distance to the target defines a landscape over that shape, and the solver walks downhill.

Each step evaluates the gradient of the distance with respect to every concentration. The app computes that gradient exactly, by differentiating the whole chain above (K/S mixing, the inverse formula, the integration, the cube root inside OKLab). It then takes a step against the gradient, projects the result back onto the simplex so the concentrations still sum to one, and keeps the step only if the distance fell. The step size grows after a success and halves after a failure. The loop runs for up to 400 steps or until the distance is below 0.00001.

Descent finds the nearest valley, not necessarily the lowest, so the solver starts from several places and keeps the best result: a least-squares fit in K/S space, an even mix of everything, a mix dominated by the single paint closest to the target, and, when the gamut map has already solved a nearby cell, that cell's answer. Finally it tries to simplify: paints contributing less than 15 percent by amount are dropped one at a time and the mix re-solved, and a drop is kept if the distance grows by less than 0.004 and does not cross the 0.03 line.

Three paints, one target. Shading shows the distance to the target across every possible mix, lighter meaning closer. Three starting points descend at once and the best result is kept, then rounded to knife-parts. This demo uses a finite-difference gradient and 60 steps; the app uses the exact gradient and up to 400.

The last step turns concentrations into something a knife can measure. Concentration is not the same as the amount you squeeze out: a strong tinter needs less. The app converts with amount = √(concentration / strength), where strength is the paint's tinting strength squared times its luminance, following the spectral.js convention. It then searches for the small-integer ratio, up to sixteen parts in total, that best matches those amounts, penalising longer ratios slightly, and reduces by the greatest common divisor. The colour that ratio would actually produce is mixed again and its distance reported as the rounding cost. Mixing order follows tinting strength: the weakest, lightest paint first, the strongest last.

6. Drawing the map

The map on the wheel is the same solver run thousands of times. Lightness is split into 41 levels. At each level a 56 by 56 grid is laid over the wheel, and the colour at the centre of every cell is handed to the same recipe solve described above. The cell stores the resulting distance as a byte (the distance times 800, rounded down). Cells at or above 24, which is 0.03, are drawn under the dark veil; the white line is a contour traced between cell centres at exactly that value.

That is a lot of solves, so the sweep is organised to be useful early. The level under your slider is computed first, then every second level moving outward, then the levels in between; while a level waits, the map shows an average of its two finished neighbours. Each level is solved coarse first, on a 28 by 28 grid, and then only the cells the boundary runs through are re-solved at full resolution, warm-started from their coarse parent. The boundary is a curve, so that is a few hundred cells rather than three thousand. The work is spread across background threads, one per processor core, leaving one core free so the wheel keeps responding.

The sweep for one lightness level of the Zorn palette. The coarse pass fills in first, cell by cell; then only the cells along the boundary are split and re-solved; then the boundary is drawn. This demo uses a 22 by 22 grid and short solves, so its boundary is rougher than the app\u2019s.

Two details keep the map and the recipe card in agreement. The card's solve for the exact colour under the crosshair is warm-started from the cells around it, so it can only do as well or better than the map. And when it does better by more than a byte, the improved value is written back into that cell, so a spot you have inspected is never drawn as unreachable when a recipe exists for it.

7. What the numbers rest on

Everything runs in your browser. Nothing you enter is sent anywhere; the only network traffic is loading the page and the usage counts described on the privacy page.

References

See it run on your own tubes

Open Halftint