Learn Creative Coding (#28) - Color Palettes from Data and Algorithms
We covered color theory back in episode 7. RGB, HSL, complementary colors, the 60-30-10 rule. That was foundations. But for generative art -- especially art you want to sell, exhibit, or mint -- knowing color theory isn't enough. You need systems. A way to generate palettes algorithmically, extract them from photographs, curate them from libraries, and assign them to elements with roles. One seed, one palette, one cohesive piece. Every time.
Color is the single biggest lever you can pull in generative art. You can have a gorgeous composition algorithm from episode 25, beautiful typography from episode 26, perfect texture from last episode -- and if the colors clash or feel random, the whole thing falls apart. Conversely, a simple grid of circles with a carefully chosen palette looks better than a complex particle system in ugly colors. I've seen it over and over in my own work and in the generative art community. Color is what people notice first and judge hardest.
This episode builds on the seed system from episode 24. Our palette generators will be deterministic -- same seed, same palette. We'll extract colors from photographs using k-means clustering, generate harmonious palettes from math, maintain curated palette libraries, and build a complete palette system that assigns color roles automatically. By the end you'll have a self-contained color engine that plugs into any generative piece.
Extracting palettes from images
One of the best sources for color palettes: photographs. Nature, architecture, film stills -- they've already been "designed" by light and reality. A sunset photograph contains warm oranges, deep purples, and dusty pinks that work together because that's how actual light behaves. A forest photograph gives you mossy greens, bark browns, and filtered golden light. The image does the curation for you.
Simple pixel sampling
The technique is straightforward. Load an image onto a canvas, sample random pixels, then cluster the samples to find the dominant colors. We're reusing the pixel reading approach from episode 10 here -- draw something to a canvas, read the pixel data, do something interesting with it:
function extractPalette(img, numColors) {
let canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
let ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
let pixels = imageData.data;
// sample random pixels
let samples = [];
for (let i = 0; i < 1000; i++) {
let idx = Math.floor(Math.random() * (pixels.length / 4)) * 4;
samples.push({
r: pixels[idx],
g: pixels[idx + 1],
b: pixels[idx + 2]
});
}
// simple k-means clustering
return kMeans(samples, numColors);
}
We sample 1000 random pixels rather than analyzing every single one. That's plenty for color extraction -- even a 100x100 image has 10,000 pixels, and most photographs have far more. Sampling keeps it fast without sacrificing quality. The actual magic happens in the k-means step.
K-means clustering
K-means groups similar colors together and finds the "center" of each group. It's probably the simplest clustering algorithm that actually works well, and it's perfect for palette extraction because colors that are "close" in RGB space tend to look similar to human eyes:
function kMeans(samples, k, iterations = 20) {
// initialize centroids randomly from samples
let centroids = [];
let used = new Set();
for (let i = 0; i < k; i++) {
let idx;
do { idx = Math.floor(Math.random() * samples.length); }
while (used.has(idx));
used.add(idx);
centroids.push({ ...samples[idx] });
}
for (let iter = 0; iter < iterations; iter++) {
// assign each sample to nearest centroid
let clusters = Array.from({ length: k }, () => []);
for (let sample of samples) {
let minDist = Infinity;
let closest = 0;
for (let c = 0; c < k; c++) {
let dr = sample.r - centroids[c].r;
let dg = sample.g - centroids[c].g;
let db = sample.b - centroids[c].b;
let dist = dr * dr + dg * dg + db * db;
if (dist < minDist) {
minDist = dist;
closest = c;
}
}
clusters[closest].push(sample);
}
// update centroids to cluster averages
for (let c = 0; c < k; c++) {
if (clusters[c].length === 0) continue;
let sumR = 0, sumG = 0, sumB = 0;
for (let s of clusters[c]) {
sumR += s.r;
sumG += s.g;
sumB += s.b;
}
centroids[c] = {
r: Math.round(sumR / clusters[c].length),
g: Math.round(sumG / clusters[c].length),
b: Math.round(sumB / clusters[c].length)
};
}
}
return centroids.map(c => `rgb(${c.r}, ${c.g}, ${c.b})`);
}
The algorithm: pick k random starting points (centroids), assign every sample to the nearest centroid, then move each centroid to the average of its assigned samples. Repeat 20 times. Each iteration, the centroids drift toward the densest clusters of similar colors. After 20 passes they've usually converged -- the centroids represent the k most "representative" colors in the image.
The distance metric here is squared Euclidean distance in RGB space (dr*dr + dg*dg + db*db). It's not perceptually perfect -- human eyes are more sensitive to green than blue, so two colors that look equally different to you might have different RGB distances. For professional color science you'd use CIELAB or CIEDE2000. But for art? RGB distance is good enough. The palettes you extract will look right.
Feed in a sunset photo, get warm oranges and deep purples. Feed in a forest photo, get mossy greens and bark browns. The image does the creative work.
Algorithmic color harmony
Instead of sampling images, you can generate palettes from color theory rules. This is where the stuff from episode 7 comes back in a much more practical way.
HSL is your friend
Work in HSL (Hue, Saturation, Lightness) for palette generation. RGB is great for computers but terrible for humans thinking about color relationships. In HSL, "rotate the hue by 180 degrees" means "complementary color." In RGB, good luck doing that intuitively.
function hslToHex(h, s, l) {
s /= 100;
l /= 100;
let c = (1 - Math.abs(2 * l - 1)) * s;
let x = c * (1 - Math.abs((h / 60) % 2 - 1));
let m = l - c / 2;
let r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
r = Math.round((r + m) * 255);
g = Math.round((g + m) * 255);
b = Math.round((b + m) * 255);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
The math looks heavy but the concept is simple: convert hue angle + saturation percentage + lightness percentage into the red/green/blue values a screen needs. You write this once and forget about it. From now on you think purely in hue angles, saturation levels, and lightness values. Way more intuitive for designing color systems.
Harmony rules
Classical color harmony boils down to geometric relationships on the color wheel. The hue is a circle (0-360 degrees), and different angular relationships produce different emotional effects:
function complementary(baseHue, s, l) {
return [
hslToHex(baseHue, s, l),
hslToHex((baseHue + 180) % 360, s, l)
];
}
function analogous(baseHue, s, l, spread = 30) {
return [
hslToHex((baseHue - spread) % 360, s, l),
hslToHex(baseHue, s, l),
hslToHex((baseHue + spread) % 360, s, l)
];
}
function triadic(baseHue, s, l) {
return [
hslToHex(baseHue, s, l),
hslToHex((baseHue + 120) % 360, s, l),
hslToHex((baseHue + 240) % 360, s, l)
];
}
function splitComplementary(baseHue, s, l) {
return [
hslToHex(baseHue, s, l),
hslToHex((baseHue + 150) % 360, s, l),
hslToHex((baseHue + 210) % 360, s, l)
];
}
function tetradic(baseHue, s, l) {
return [
hslToHex(baseHue, s, l),
hslToHex((baseHue + 90) % 360, s, l),
hslToHex((baseHue + 180) % 360, s, l),
hslToHex((baseHue + 270) % 360, s, l)
];
}
One base hue, one function call, a complete harmonious palette. Complementary (180 degrees apart) for maximum contrast -- think orange and blue, red and cyan. Analogous (neighbors on the wheel) for calm, unified feels -- think autumn leaves, ocean gradients. Triadic (three evenly spaced) for vibrance without clashing. Split complementary gives you the tension of complementary but with more variety -- I use this one the most in my own work because it's versatile. And tetradic (four corners of a square on the wheel) gives you the richest palette but is hardest to balance.
Adding variation within a palette
Raw harmony rules give you hues, but a real palette needs variation in saturation and lightness too. Five colors at the same saturation and lightness look flat. You need darks, mids, and lights:
function generatePalette(baseHue, R) {
let harmony = R.pick(['analogous', 'complementary', 'triadic', 'splitComplementary']);
let hues;
switch (harmony) {
case 'analogous':
hues = [baseHue - 25, baseHue, baseHue + 25];
break;
case 'complementary':
hues = [baseHue, (baseHue + 180) % 360];
break;
case 'triadic':
hues = [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
break;
case 'splitComplementary':
hues = [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
break;
}
let palette = [];
// for each hue, generate light, mid, and dark variants
for (let hue of hues) {
let sat = 50 + R.random(0, 30);
palette.push(hslToHex(hue, sat, 25 + R.random(0, 10))); // dark
palette.push(hslToHex(hue, sat, 50 + R.random(0, 15))); // mid
palette.push(hslToHex(hue, sat - 15, 80 + R.random(0, 10))); // light
}
return palette;
}
Now each hue gets three values: dark, mid, light. The saturation drops slightly for the light variants (that -15) because highly saturated pastels look garish. The randomness in saturation and lightness (controlled by our seeded R from episode 24) means the palette isn't mathematically sterile -- it has some organic wobble while staying within harmonious bounds.
Notice the R.pick choosing the harmony type? That means the seed determines not just which hues you get, but which harmony strategy produces them. Different seeds explore different parts of the color wheel AND different harmonic relationships. The possibility space is enormous from a single parameter.
Curated palette libraries
Sometimes math isn't enough. Human-curated palettes have a certain quality that algorithms struggle to reproduce. The best generative art often uses a mix: curated palettes as the base, algorithmic adjustments on top.
Hardcoded curated palettes
Build a collection of palettes you know work. Steal from nature, steal from film, steal from other artists (palettes aren't copyrightable). Here's a starter set:
const PALETTES = [
// warm sunset
['#264653', '#2a9d8f', '#e9c46a', '#f4a261', '#e76f51'],
// midnight blues
['#0d1b2a', '#1b263b', '#415a77', '#778da9', '#e0e1dd'],
// neon cyber
['#f72585', '#b5179e', '#7209b7', '#560bad', '#480ca8'],
// forest
['#2d6a4f', '#40916c', '#52b788', '#74c69d', '#b7e4c7'],
// fire
['#03071e', '#370617', '#6a040f', '#9d0208', '#dc2f02'],
// pastel dream
['#ffadad', '#ffd6a5', '#fdffb6', '#caffbf', '#9bf6ff'],
// monochrome warm
['#3d2c2e', '#6b4c4e', '#a07070', '#c9a0a0', '#f0d0d0'],
// ocean
['#005f73', '#0a9396', '#94d2bd', '#e9d8a6', '#ee9b00'],
];
For generative art, 8-12 solid palettes is enough. Your algorithm picks one per seed. The palettes above cover a wide range of moods -- warm, cool, dark, pastel, monochrome, saturated. Each one has been tested by designers and appears in thousands of projects. You could spend years trying to algorithmically generate something as good as that "warm sunset" palette. Or you could just use it :-)
The sources I pull from: coolors.co has thousands of community-curated palettes. Colour Lovers (now part of Adobe Color) has an archive going back decades. And honestly? Screenshots from films. Wes Anderson movies are a goldmine. Studio Ghibli. Any director who works closely with a colorist. Pause on a beautiful frame, sample 5 colors, add to your library.
The 60-30-10 rule revisited
From episode 7: 60% dominant color, 30% secondary, 10% accent. In code:
function applyPaletteRule(palette, R) {
let shuffled = R.shuffle([...palette]);
return {
background: shuffled[0], // 60% - used for background
primary: shuffled[1], // 30% - main shapes
secondary: shuffled[2], // extra variety
accent: shuffled[3], // 10% - highlights and details
detail: shuffled[4] || shuffled[0] // tiny touches
};
}
// usage
let colors = applyPaletteRule(R.pick(PALETTES), R);
// background gets 60%
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, width, height);
// most shapes use primary
// accent only for special elements
The shuffle is seeded (through R.shuffle from episode 24), so which color plays which role changes per seed. The same palette produces wildly different moods depending on which color is the background vs the accent. A dark blue background with orange accent feels cinematic. Swap them -- orange background, blue accent -- and it feels playful. Same five colors, completly different energy.
This role assignment is what separates random coloring from deliberate color design. When every element in your piece pulls from a named role rather than a random array index, the output has visual consistency. The background color appears once, everywhere. The primary color dominates the shapes. The accent color appears sparingly, in the most important spots. Hierarchy through color.
Color temperature control
Sometimes you need all palettes to feel consistently warm or cool, regardless of which specific palette the seed chose:
function warmShift(hex, amount) {
let { h, s, l } = hexToHsl(hex);
// push toward warm (red-yellow range: 0-60)
if (h > 180) h = h - amount;
if (h < 180) h = h + amount * 0.3;
s = Math.min(100, s + amount * 0.2);
return hslToHex(h, s, l);
}
function coolShift(hex, amount) {
let { h, s, l } = hexToHsl(hex);
// push toward cool (blue range: 180-270)
if (h < 180) h = h + amount;
if (h > 270) h = h - amount * 0.5;
return hslToHex(h, s, l);
}
Apply this globally to shift an entire palette warmer or cooler. Usefull for seasonal variations, time-of-day effects, or mood control across a collection. A warm-shifted "midnight blues" palette becomes a dusty twilight. A cool-shifted "fire" palette becomes volcanic ash. Small hue rotations, big emotional changes.
The amount * 0.3 for warm shifts on already-warm colors prevents them from overshooting into pure red. And the saturation bump (s + amount * 0.2) on warm shifts is because warm colors feel more intense to human eyes -- we expect warmth and saturation to correlate. These are tiny adjustments but they make the temperature shift feel natural rather than mechanical.
Contrast checking
For generative art that includes text (think back to the typography in episode 26) or needs clear separation between overlapping elements:
function luminance(hex) {
let r = parseInt(hex.slice(1, 3), 16) / 255;
let g = parseInt(hex.slice(3, 5), 16) / 255;
let b = parseInt(hex.slice(5, 7), 16) / 255;
r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(hex1, hex2) {
let l1 = luminance(hex1);
let l2 = luminance(hex2);
let lighter = Math.max(l1, l2);
let darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// ratio > 4.5 = good for text
// ratio > 3 = acceptable for large elements
// ratio > 7 = excellent contrast
The luminance formula is the WCAG standard -- it accounts for how human eyes perceive brightness differently across red, green, and blue channels (green contributes most, blue least, which is why we weight them 0.7152, 0.2126, and 0.0722 respectively). The gamma correction step (that Math.pow business) converts from sRGB to linear light, because monitors display color non-linearly.
When your algorithm picks a background and foreground color, check the contrast. If it's too low, adjust:
function ensureContrast(bg, fg, minRatio) {
if (contrastRatio(bg, fg) >= minRatio) return fg;
// adjust lightness until contrast is sufficient
let { h, s, l } = hexToHsl(fg);
let bgLum = luminance(bg);
// if bg is dark, make fg lighter; if bg is light, make fg darker
let direction = bgLum < 0.5 ? 1 : -1;
while (contrastRatio(bg, hslToHex(h, s, l)) < minRatio && l > 0 && l < 100) {
l += direction * 5;
}
return hslToHex(h, s, Math.max(0, Math.min(100, l)));
}
This preserves the hue and saturation of your chosen color but pushes lightness until the contrast is readable. It's a safety net. Most of the time your palette will have enough contrast naturally. But when a seed happens to pick a mid-tone background with a mid-tone foreground, this catches it automatically. Better than generating a beautiful poster that nobody can read.
A complete palette system for generative art
Here's what ties it all together -- a self-contained palette generator that plugs into any piece. One seed goes in, a complete color system comes out:
function createPaletteSystem(seed) {
let R = createArt(seed);
// pick base approach
let approach = R.weighted([
{ value: 'curated', weight: 4 },
{ value: 'algorithmic', weight: 3 },
{ value: 'monochrome', weight: 2 },
]);
let colors;
switch (approach) {
case 'curated':
colors = R.pick(PALETTES);
break;
case 'algorithmic':
let baseHue = R.random(0, 360);
colors = generatePalette(baseHue, R);
break;
case 'monochrome':
let hue = R.random(0, 360);
let sat = R.random(20, 60);
colors = [
hslToHex(hue, sat, 10),
hslToHex(hue, sat, 30),
hslToHex(hue, sat, 50),
hslToHex(hue, sat, 70),
hslToHex(hue, sat, 90),
];
break;
}
return applyPaletteRule(colors, R);
}
One function. One seed. A complete, harmonious palette with assigned roles. The R.weighted call means curated palettes appear 4/9 of the time (the safest option), algorithmic palettes 3/9, and monochrome 2/9. The weights reflect a creative decision: curated palettes are reliable, algorithmic ones are more surprising, monochrome is a nice occasional variation. Tweak the weights to match your aesthetic preference.
This is what makes generative art look professional across thousands of outputs. Every single piece has a coherent color scheme. Not because you hand-picked colors for each one, but because you designed a system that picks good colors automatically. The seed determines which approach, which hue, which palette, which role assignment -- but the system guarantees that whatever it picks will work together.
't Komt erop neer...
- Extract palettes from photos using k-means clustering on sampled pixels
- HSL is better than RGB for palette generation -- hue relationships are intuitive
- Harmony rules: complementary (contrast), analogous (calm), triadic (vibrant), split-complementary (balanced)
- Vary saturation and lightness within each hue for depth -- dark, mid, light variants
- Curated palettes (8-12 proven sets) are often better than pure algorithmic ones
- 60-30-10 rule: dominant, secondary, accent -- assign roles to your colors, don't pick randomly
- Always check contrast for readability; adjust lightness if needed
- Temperature shifting pushes entire palettes warmer or cooler for mood control
- Build a palette system: one seed, one harmonious, role-assigned palette
- The system guarantees good color across thousands of outputs -- that's the whole point
We've been building up a full generative art toolkit across this phase. Seeds for reproducibility, composition algorithms for layout, typography as visual material, texture for surface quality, and now color systems for palettes. The next step is taking all of that and preparing it for output -- not just screenshots, but proper export formats like SVG and high-resolution PNG that are ready for print, exhibition, or the blockchain. Getting your art out of the browser and into the world is its own set of challenges, and it matters more than you'd think :-)
Sallukes! Thanks for reading.
X