Machine learning lab · notes
Palette Grade, how it works
A palette tool built on unsupervised learning. No ML libraries and no server. Every step is a small pure TypeScript function that runs in a Web Worker in your browser.
The problem
People build palettes from photos all the time: for a brand, a website, a wedding, a room. The usual advice is to pull five or six colours from the image, then check contrast in a separate tool, then decide by hand which colour is the background and which is the text.
Common extractors make that harder. Some return averaged colours that never appear in the image. Some sample a fixed handful of points and accept only JPEG. Many never say whether your photo is uploaded. Palette Grade does the whole job in one place: it reads every pixel, picks the number of colours itself, says how much of the frame each colour covers, suggests roles, checks every pair against WCAG and hands back code. The image is decoded with canvas on your device and never sent anywhere.
Pixels in, CIELAB out
Each image is drawn onto a canvas scaled to about 20,000 pixels and read back with getImageData. Fully transparent pixels are skipped. With several images each one gets an equal share of the 20,000, so a moodboard of eight frames weighs every frame the same instead of letting the biggest file win.
Why CIELAB and not RGB
Distance in RGB does not match what the eye sees. Two greens can be far apart in RGB yet look the same, while a small step in dark blue looks large. CIELAB was designed so that straight line distance, called ΔE, roughly tracks perceived difference. Each pixel goes sRGB, then linear light, then XYZ under a D65 white, then L*a*b*. The inverse of the same maths turns a cluster back into a hex code.
Clustering and choosing k
k-means splits the pixels into k groups so that each pixel is close to its group's centre. Starts are spread with k-means++, then Lloyd iterations assign every pixel to its nearest centre and move each centre to the mean of its pixels until nothing changes. Twelve seeded restarts run and the lowest inertia wins, so the same image always gives the same palette.
Silhouette picks k
Silhouette asks each point how much closer it is to its own group (a) than to the nearest other group (b), scored as (b - a) / max(a, b). It needs every pairwise distance, which is quadratic, so the sweep over k = 3 to 10 runs on an even 1,500 pixel subsample. The winning k is then fitted on the full sample, whose centres become the palette and whose labels give each colour its pixel share. The k chart shows the score per k, and the lock buttons override it.
Merging and snapping
Two centres closer than ΔE 5 read as one colour, so they merge into one swatch with their shares added. Each swatch is then snapped to the real pixel nearest its centre, so every hex in the palette exists in your image.
The accent slot
k-means minimises total squared error, so a lamp or a logo that covers half a percent of the frame costs it almost nothing to ignore. Those are often the colours you wanted. After clustering, any pixel with chroma of at least 30 that sits more than ΔE 20 from every swatch is collected. If enough of them exist (at least 0.1% of the sample), they are clustered into up to three groups and the most saturated one becomes an extra swatch, marked as kept by the accent slot. Its pixels move out of the swatches that had absorbed them.
Roles
The largest swatch is the background. The most saturated remaining swatch with chroma of at least 30 is the accent. The one with the highest contrast against the background is the text, and the one closest to the background in ΔE is the surface. Anything left is support. These are starting points, not rules, and the contrast grid shows every other pairing.
Contrast maths
WCAG 2 compares relative luminance: 0.2126 R + 0.7152 G + 0.0722 B on linearised channels, then (lighter + 0.05) / (darker + 0.05). Black on white gives exactly 21:1. Body text needs 4.5:1 for AA and 7:1 for AAA, and large text or UI parts need 3:1. Ratios are shown rounded down, so 4.499 never displays as a pass.
Graded like
For the film matches the subsample is also clustered into exactly six colours, sorted dark to light and compared with forty hand picked film palettes as an 18 number vector. The three nearest by squared distance are shown.
Results
Measured by running the same functions in Node 24 on the bundled samples at about 20,000 pixels each. In the browser the worker times land in the same range, and the page never blocks while it runs.
| Sample | Best k | Silhouette | Colours | Accent slot kept | Text on background | Grade time |
|---|---|---|---|---|---|---|
| Lamplit study | 4 | 0.508 | 5 | #e77f3a · 0.8% | 9.30:1 | about 0.6 s |
| Impact | 8 | 0.517 | 9 | #002c7a · 0.7% | 11.43:1 | about 1.0 s |
| Dark site | 3 | 0.709 | 4 | #ca9f54 · 0.7% | 8.46:1 | about 0.3 s |
| All three pooled | 3 | 0.541 | 4 | #9a393f · 1.5% | 6.29:1 | about 0.4 s |
In every sample the accent slot kept a colour plain k-means had absorbed: the desk lamp in the study, the gold in the dark site's artwork. On Impact it kept a deep blue rather than the red glow, because CIELAB rates that blue as more saturated.
Limits
CIELAB is weak on blues. Its distances bend around blue and purple, so blues can look more saturated or more different than they are. Newer spaces such as OKLab fix much of this.
k-means still drops small colours. The accent slot rescues one vivid colour. A second small accent, or a small muted one, can still vanish into a larger swatch. Lock a higher k to see more.
Downscaling blends edges. At 20,000 pixels thin lines and fine text mix with their surroundings, which can create in between colours.
Roles are a guess. The largest colour in a photo is not always the right page background, and a palette that passes contrast can still clash. The film palettes are my approximations from memory, so the matches are for fun.