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.
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.
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.
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 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.
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 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.
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
- Chart-derived colours. Every paint's spectrum is estimated from a published chart colour, not measured from real paint. The relationships the engine works out, such as what mixes, in what order and roughly how much, hold up well on that data. The absolute swatch is where the error lives. I plan to measure real drawdowns with a spectrophotometer and replace the approximate labels as each line is done.
- Tinting strengths. These are estimated from pigment composition rather than measured. They affect the knife-part amounts and the mixing order, but not which colours are reachable.
- Opaque films. The Kubelka–Munk relation used here assumes an opaque, even layer of paint. Glazes, scumbles and thin washes behave differently, and that is also why the app does not attempt watercolour.
- Phone photos. The photo picker compares colours within one frame. It does not try to read absolute colour from a camera, because wet paint and phone image processing make that unreliable.
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
- Sochorová, Š. & Jamriška, O. (2021). Practical Pigment Mixing for Digital Painting. ACM Transactions on Graphics 40(6), SIGGRAPH Asia 2021.
- Kubelka, P. & Munk, F. (1931). Ein Beitrag zur Optik der Farbanstriche. Zeitschrift für technische Physik 12, 593–601.
- Ottosson, B. (2020). A perceptual color space for image processing (OKLab).
- spectral.js by Ronald van Wijnen, MIT licence: the base spectra and colour tables the engine and these diagrams use.