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.
- Tube coloursRGB from a chart
- Spectrum38 reflectance bands
- K/Sabsorb ÷ scatter
- Mixweighted by concentration
- Spectrumback to reflectance
- OKLabvia XYZ
- ΔEdistance to the target
- 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.
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.
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.
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 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.
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.
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
- Chart-derived colours. Every paint's spectrum is estimated from a published chart colour, not measured from a paint film. The relationships the engine computes (what mixes, in what order, 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 lines are done.
- Tinting strengths. Estimated from pigment composition, not measured. They affect the knife-part amounts and the mixing order, not which colours are reachable.
- Opaque films. The Kubelka–Munk relation used here assumes an opaque, uniform layer. Glazes, scumbles and thin washes behave differently, which 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.