paint-mixing

Paint mixing for the questions painters actually ask at the palette:

  1. What do I add? The pile is nearly right and needs to be a little lighter and warmer. Which paint, how much (as a share of the pile), and what else will it do?
  2. Can these tubes reach it? Given the paints you own, the nearest mix to a colour, as a recipe in parts, and how far off it is.
  3. One shared palette for a value plan. Squint at a picture, cut it into value masses, and choose one pile per mass so the plan needs few different tubes, with each pile checked against its value band.

It is a small, dependency-free JavaScript library built on a two-constant Kubelka–Munk model of oil paint. It runs in Node and in the browser from plain <script> tags.

Try the demo: all three questions, with Vermeer’s Milkmaid as the value-plan example.

The colours are estimates, not measurements. The paints are modelled from estimated masstones, hiding power and tinting strength for ordinary artist-grade oils, not measured spectra of any brand. Treat every answer as a first pile and judge it by eye. Directions (which way a paint pushes a pile) are far more reliable than absolute colours.

Use

No package registry yet: clone the repository, or copy the files in src/.

const { Steer, Plan, ReachMix, Spectral, historicalTubes } = require("./paint-mixing");

In a browser, load the files in this order; they define window.ReachMix, Spectral, Steer and Plan:

<script src="src/reach-mix.js"></script>
<script src="src/spectral.js"></script>
<script src="src/steer.js"></script>
<script src="src/plan.js"></script>

1. What do I add?

Piles are { tubeId: parts } over the 33-paint library in Steer.TUBES (W titanium white, Y cadmium yellow light, U ultramarine, S burnt sienna and so on). An ask is the painter’s judgement by eye: value, temperature and intensity, each from −2 (a lot less) to +2 (a lot more).

const pile = { S: 2, U: 1, W: 2 };
const now = Steer.colour(pile);                                // { lab: OKLab, rgb: sRGB bytes }
const asks = { value: 1, temp: 1 };                            // a little lighter, a little warmer
const goal = Steer.goalFrom(now.lab, asks);
const { singles, pair } = Steer.suggest(pile, goal, ["W", "Y", "R", "M", "U", "P", "G", "S"]);

for (const s of singles.slice(0, 2))
  console.log(Steer.BY[s.id].name, Steer.amountWords(s.frac), Steer.fracLabel(s.frac),
              Steer.sideEffects(now.lab, s.c.lab, asks).map(e => e.text));
// Titanium White some ≈ 1/4 of the pile []
// Cadmium Yellow Light some ≈ 1/4 of the pile [ 'more intense' ]
console.log(pair && pair.ids, pair && pair.fracs);              // [ 'W', 'R' ] [ 0.25, 0.0625 ]

Amounts are fractions of the pile’s current volume, chosen with a small charge for every bit of paint added, because a correction that doubles the pile turns it to mud. A pair is offered only when two paints together do clearly better than any one. Steer.recipe(parts) collapses a history of additions into a clean parts recipe, and Steer.batch(parts, areaCm2, "normal", 0.25) says how much to mix for an area.

2. Can these tubes reach it?

const target = [0, 0, 0];
const [r, g, b] = ReachMix.hexRgb("#6f8a5a");
ReachMix.oklab(ReachMix.toLin(r), ReachMix.toLin(g), ReachMix.toLin(b), target, 0);

const fit = Steer.fresh(target, ["W", "Y", "R", "M", "U", "P", "G", "S"]);
console.log(Steer.recipe(fit.parts).lines, (100 * fit.d).toFixed(1));
// [ { id: 'G', parts: 6 }, { id: 'W', parts: 6 }, { id: 'U', parts: 1 } ] 0.9

Every mix of up to three colour tubes plus a white, in simple part ratios, is enumerated once per palette and indexed on a grid in OKLab, so lookups after the first are instant. ReachMix.buildGamut, nearestMix and tubeGain work on any tube list. tubeGain answers “which one extra tube would bring the most of this picture within reach?”.

historicalTubes() returns a second catalogue of 55 historical pigments (lead white, smalt, bone black, vermilion…) and eleven painters’ boxes (Zorn, Sargent, Hopper, Friedrich and others) for the same functions:

const H = historicalTubes(), byName = new Map(H.tubes.map(t => [t.name, t]));
const G = ReachMix.buildGamut(H.boxes.zorn.map(n => byName.get(n)));
const [i, distance] = ReachMix.nearestMix(G, ...target);
// yellow ochre 5 : bone black 4 : lead white 3, distance 4.7

3. One shared palette for a value plan

const lab = Plan.toLab(rgbaBytes, width * height);            // e.g. from canvas getImageData
const squinted = Plan.squint(lab, width, height, 7);           // blur radius in pixels: a hard squint
const cuts = Plan.valueCuts(squinted.L, 4);                    // four value bands
const masses = Plan.masses(squinted, cuts, { perBand: 2 });
const piles = Plan.piles(masses.list, cuts, ["W", "YO", "RS", "S", "IR", "RU", "BU", "IB", "U"]);
Plan.paletteOf(piles);                                         // the shared palette, most-used first
// each pile: { mass, parts, rgb, recipe, inBand, reads, dE, ... }

Recipes are chosen for the whole plan at once: each mass keeps the candidate mixes that stay in its value band and come close to its colour, and then one per mass is picked to minimise colour error plus a cost for every different tube the plan needs. On the Milkmaid with the earth palette and four values, that gives six piles from four or five tubes, all inside their bands. The exact count depends on how the picture is resized before planning: the demo’s browser resize gives five, a Lanczos resize in Python gives four. Plan.accents gives small, sharply different passages (a red roof in an ochre field) a pile of their own, and Plan.quantities totals what to squeeze out.

The model

Known limits.

Related work.

The contribution here is the painter-side solvers: what to add and how much, reach for a given set of tubes, and a shared palette across a value plan.

Files

file contents
src/reach-mix.js colour conversion, two-constant Kubelka–Munk, mix enumeration, OKLab gamut grid, nearest mix, tubeGain
src/spectral.js the same mix per wavelength: upsampling, colour matching, mixLinear
src/steer.js the 33-paint library and palettes; tinting calibration and tuning; “what do I add”; recipes; batch quantities; fresh
src/plan.js squint, value cuts, masses, accents, shared-palette piles, quantities
src/historical-tubes.js 55 historical pigments and eleven painters’ boxes, generated from claude-paint’s catalogue
index.js Node entry point
demo/ the demo page
test/ 48 tests: npm test (Node 20+; the three Milkmaid tests use Python with Pillow and are skipped without it)

Each engine file is byte-identical to the copy the painting apps on parallaxmetrology.com load: reach-mix.js in Reach, Touch of Paint, Value Plan and the Mixing Room; spectral.js in all but Reach; steer.js in Touch of Paint and Value Plan; plan.js in Value Plan. SHA-256 prefixes: reach-mix.js e6edc5f4c7a6984a, spectral.js 5441d00c24d9f3c2, steer.js 8c469afecd329a28, plan.js 987d778bbd7d24cc.

Steer keeps its paint objects and tuning in module state (strengths are calibrated once at load), so setTune affects every caller in the same process.

Licence

MIT. See LICENSE. The Kubelka–Munk model and the historical catalogue derive from claude-paint (MIT); see THIRD_PARTY_NOTICES.md.

References