How it works

How Halftint works

This page follows one colour through the program, from the number on a tube's chart to the recipe that comes out the other end, and on to the map of what a whole palette can reach. The diagrams are live. They run the same equations on the same tables as the app, right here in your browser, and they are simplified only where the caption says so.

Part one

The mechanics of a mix

Before the program can find a recipe, it needs a reliable way to predict what any given mix will look like. That prediction is a fixed chain of four steps, and it runs the same way every time. A tube colour is turned into a spectrum. The spectrum is turned into absorption and scattering. The mixing happens in that space. Then the result is turned back into a colour number that can be compared with the target. Each step is described below.

1. Converting a tube colour to a light spectrum

The program only knows each paint as a single colour: the sRGB value from the manufacturer's chart. That is not enough to mix with, because mixing has to happen wavelength by wavelength. Two pigments can look identical and still absorb quite different parts of the spectrum, and as soon as you mix them with something else the difference shows. So the first step is to turn that one colour into a plausible reflectance curve, which is a record of how much light the paint reflects at each of 38 wavelengths between 380 and 750 nanometres.

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

Pick a tube colour to see how it is built. The thin lines are the seven base spectra scaled by their weights, and the heavy line is their sum, which is the reflectance curve the engine uses for that paint. A pale colour leans almost entirely on the white base; a saturated blue leans on the cyan and blue bases.

Because the input is a chart colour rather than a measurement, this curve is an estimate. Two real paints that happen to share a chart colour would get exactly the same curve. That is the main reason the app labels chart-derived colours as approximate.

2. Converting reflectance to absorption and scattering

Reflectance does not add up when you mix paints. What does add up, near enough, is the ratio between how strongly a paint film absorbs light and how strongly it scatters it, written K/S. Kubelka and Munk showed in 1931 that for an opaque film the two are tied together by a simple formula: K/S = (1 − R)² / 2R, where R is the reflectance at that wavelength. This means that a wavelength the paint reflects well has a small K/S, and a wavelength the paint swallows has a large one. Ultramarine blue is a good example. Its reflectance is low almost everywhere except the blue end of the spectrum, so its K/S is large almost everywhere except the blue end.

Ultramarine blue. On the left is its reflectance curve; on the right is the same curve converted to K/S. The cursor picks out one wavelength on both charts, so you can see that where the reflectance is low the K/S climbs steeply.

3. Mixing paints in K/S space

To mix two or more paints, 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 turns the mixed K/S back into reflectance using the inverse of the formula above. That is the entire mixing model, and it explains things that puzzle beginners. For example, ultramarine blue and cadmium yellow give a green. This is because in the middle of the spectrum both paints have a moderate K/S, so the mix still reflects light there, while the blue end of the spectrum is absorbed by the cadmium yellow and the red end of the spectrum by the ultramarine blue. Only the green survives.

Drag the slider to change the proportions of ultramarine blue and cadmium yellow. On the left are the two K/S curves and their weighted average; on the right is the reflectance that average turns back into. The two swatches compare the pigment mix with what you would get by averaging the same two colours as light, which is what a screen does.

The two swatches under the chart show why this matters. The left one is the pigment mix. The right one is what you get if you average the same two colours as light, which is what happens when a screen blends two pixels or when a digital colour picker sits halfway between two swatches. They are different colours, and the reason is the difference between adding light and taking it away.

When two lights are combined, everything either light contains reaches your eye. Blue light and yellow light together cover most of the spectrum fairly evenly, so the result is a pale, neutral grey. When two paints are mixed, each pigment goes on absorbing what it absorbed before, so the mix only reflects the wavelengths that neither one removes. Ultramarine blue removes the red end, cadmium yellow removes the blue end, and the overlap in the middle is green. The same two starting colours give a grey one way and a green the other.

This is why colour advice built on screens can mislead a painter. An RGB colour picker, a chart made by blending swatches in image software, or a rule of thumb like "halfway between these two" all assume light-style mixing, and paint does not work that way. It is also why the engine does all its mixing in K/S space and only converts to a screen colour at the very end. Every swatch on this site and in the app is the result of a pigment mix, converted for display, rather than a blend of two display colours. And it is the reason the gamut boundary sits where it does: some colours you could make by averaging light simply cannot be made by mixing paints.

4. Converting a spectrum to a colour number

A spectrum is 38 numbers, and a colour difference needs three. To get there, the program integrates the reflectance against the CIE 1931 colour matching functions, which are the sensitivity curves of the three cone types in the eye under daylight. That gives XYZ. XYZ is then converted to OKLab, a colour space designed so that equal distances look like equal differences to a person. The ΔE you see on a recipe is simply the 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 moves through the wavelengths, and the OKLab coordinates and the final swatch follow from them.

The app shows that distance directly. A difference of 0.01 is hard to see at all. Anything under 0.03 the app treats as 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 to make it easier to read, so a 3 there means 0.03 here.

Part two

How it finds a recipe

Once the program can predict any mix, finding a recipe becomes a search: which concentrations bring the predicted colour closest to the target? The answer is found by running the chain from part one inside a loop. Predict, measure, adjust, and repeat until the distance stops falling. Then the raw answer is tidied into something you can measure with a knife. The gamut map is the same loop, run once for every cell of the wheel.

1 Start from several guesses an even mix · the nearest single paint · a least-squares fit · the neighbouring map cell 2 Predict the mix colour the four-step chain from part one, run on the current concentrations 3 Measure the distance to the target ΔE in OKLab 4 Close enough, or out of steps? below 0.00001, or 400 steps taken 5 Keep the best run each starting point ran the loop; the lowest distance wins 6 Simplify drop paints under 15% if the distance grows by less than 0.004 and stays under 0.03 7 Round to knife-parts and order small whole numbers; weakest, lightest paint first, strongest last yes no: adjust the concentrations, predict again

5. Searching for the recipe

A recipe is a set of concentrations that add up to one, with none of them negative. For three paints you can picture that as a triangle. Each corner is a single paint, each edge is a two-paint mix, and everything inside is a three-paint mix. With six paints it is the same idea in more dimensions than we can draw. The distance to the target gives every point in that shape a height, and the solver's job is to walk downhill.

At each step it works out the gradient, which is how much the distance would change if each concentration were nudged a little. The app computes that exactly, by differentiating the whole chain from part one, including the inverse K/S formula, the integration and the cube root inside OKLab. It then takes a step in the downhill direction, pushes the result back onto the shape so the concentrations still add up to one, and keeps the step only if the distance actually fell. If it did, the next step is a little bigger. If it did not, the step is halved and tried again. The loop runs for up to 400 steps, or stops early once the distance is below 0.00001.

Walking downhill only finds the nearest valley, and there can be more than one. So the solver starts from several different places and keeps whichever run ends lowest. The starting points are an even mix of everything, a mix dominated by the single paint nearest the target, a quick least-squares fit made in K/S space, and, when the gamut map has already solved a nearby cell, that cell's answer. Finally it tries to simplify the winner. Any paint contributing less than 15 percent of the mix by amount is dropped and the mix is re-solved without it, and the drop is kept only if the distance grows by less than 0.004 and does not cross the 0.03 line.

The descent for a sage green target, from titanium white, yellow ochre and ultramarine blue. Each line is one starting point. The distance to the target falls step by step and levels off where that run can go no further. The run that starts from almost pure yellow ochre never moves: from that corner of the triangle, every step the gradient suggests makes the colour worse before it makes it better, so the descent gives up immediately. The swatches show the best run’s mix colour at successive steps beside the target, and the result rounded to knife-parts. This demo uses a finite-difference gradient; the app uses the exact gradient and up to 400 steps.

The flat line in that figure is not a mistake. It is the clearest example of why one starting point is not enough. From a corner of the triangle, where the mix is almost entirely one paint, the slope the solver measures points in a direction that a small step cannot follow without leaving the triangle, and once the step is pushed back onto the edge the colour is no better than before. The solver tries smaller and smaller steps, finds none that helps, and stops. The run that started from an even mix gets much further but settles in a shallow valley well short of the target. Only the run that started from a neighbouring map cell finds the real answer. Keeping the best of several runs turns a method that can get stuck into one that rarely does.

6. Converting concentrations to knife-parts

Concentrations are not the same as the amounts you squeeze out. A strong tinter needs less of itself to reach a given concentration, so the app converts using amount = √(concentration ÷ strength), where a paint's strength is its tinting strength squared times its luminance, following the spectral.js convention. It then looks for the small whole-number ratio, with up to sixteen parts in total, that comes closest to those amounts, with a small penalty for longer ratios, and divides through by any common factor. Because rounding changes the colour a little, the app mixes the rounded recipe again and reports how far it lands from the target as the rounding cost. Mixing order follows tinting strength: the weakest and lightest paint goes down first and the strongest goes in last.

7. 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 grid of 56 by 56 cells is laid over the wheel, and the colour at the centre of each cell is handed to exactly the same recipe solve described above. The cell keeps the resulting distance as a single byte, which is the distance times 800, rounded down. Any cell at 24 or above, which is a distance of 0.03, is drawn under the dark veil, and the white line is a contour traced between cell centres at that value.

That is a lot of solves, so the sweep is arranged to become useful as early as possible. The level under your slider is computed first. Then every second level is computed working outward, and only after that the levels in between. While a level waits its turn, the map shows an average of its two finished neighbours. Each level is also solved coarsely 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. All of this runs in background threads, one per processor core, with one core left free so the wheel keeps responding while you drag.

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, and finally 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 as the map or better. And when it does better by more than a byte, the improved value is written back into that cell, so a spot you have looked at is never drawn as unreachable when a recipe for it exists.

8. Knowing the limits of the data

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