Palettize

Redraw a photo using a limited color palette derived from the image

Source Code for this Sketch

let image1
let image2
let source
let target
let rgb
let mode = "target"

// From Patt Vira: https://editor.p5js.org/pattvira/sketches/se7PWxfCD
// Convert RGB to LAB color space (better for color clustering)
function rgbToLab(r, g, b) {
	r /= 255; g /= 255; b /= 255;
	[r, g, b] = [r, g, b].map(c =>
		(c > 0.04045) ? pow((c + 0.055) / 1.055, 2.4) : c / 12.92
	);
	let x = r * 0.4124 + g * 0.3576 + b * 0.1805;
	let y = r * 0.2126 + g * 0.7152 + b * 0.0722;
	let z = r * 0.0193 + g * 0.1192 + b * 0.9505;
	[x, y, z] = [x / 0.95047, y / 1.000, z / 1.08883];
	[x, y, z] = [x, y, z].map(c =>
		(c > 0.008856) ? pow(c, 1 / 3) : (7.787 * c) + 16 / 116
	);
	return {
		l: (116 * y) - 16,
		a: 500 * (x - y),
		b: 200 * (y - z)
	};
}

// From Patt Vira: https://editor.p5js.org/pattvira/sketches/se7PWxfCD
// Convert LAB back to RGB
function labToRgb(lab) {
	let y = (lab.l + 16) / 116;
	let x = lab.a / 500 + y;
	let z = y - lab.b / 200;
	[x, y, z] = [x, y, z].map(c =>
		pow(c, 3) > 0.008856 ? pow(c, 3) : (c - 16 / 116) / 7.787
	);
	x *= 0.95047; y *= 1.000; z *= 1.08883;
	let r = x * 3.2406 + y * -1.5372 + z * -0.4986;
	let g = x * -0.9689 + y * 1.8758 + z * 0.0415;
	let b = x * 0.0557 + y * -0.2040 + z * 1.0570;
	[r, g, b] = [r, g, b].map(c =>
		(c > 0.0031308) ? (1.055 * pow(c, 1 / 2.4) - 0.055) : c * 12.92
	);
	return {
		r: constrain(int(r * 255), 0, 255),
		g: constrain(int(g * 255), 0, 255),
		b: constrain(int(b * 255), 0, 255)
	};
}

function kMeans(colors, k) {
	// Guard in case k is larger than the data. We can't calculate k-means
	// in that case.
	if (k > colors.length) {
		return
	}

	// Initialize a random cluster for each point
	for (let i = 0; i < colors.length; i++) {
		colors[i].k = Math.floor(random(0, k))
	}

	let clusters = []

	// Pick random centroids and initialize each cluster (1 per k)
	for (let i = 0; i < k; i++) {
		clusters[i] = {}
		clusters[i].l = colors[i].l
		clusters[i].a = colors[i].a
		clusters[i].b = colors[i].b
		clusters[i].size = 0
		clusters[i].totalL = 0
		clusters[i].totalA = 0
		clusters[i].totalB = 0
	}

	let foundChanges = 1
	let totalChanges = 0
	let cycleLimit = 100

	// Calculate clusters
	while (foundChanges !== 0) {
		foundChanges = 0

		for (let i = 0; i < colors.length; i++) {
			// Calculate the nearest cluster for this point
			let lowestDistance = Number.MAX_SAFE_INTEGER
			let k = colors[i].k
			for (let j = 0; j < clusters.length; j++) {
				let distance = dist(
					clusters[j].l, clusters[j].a, clusters[j].b,
					colors[i].l, colors[i].a, colors[i].b)

				if (distance < lowestDistance) {
					lowestDistance = distance
					k = j
				}
			}
			colors[i].k = k

			// Re-calculate centroid for the cluster
			clusters[colors[i].k].size++
			clusters[colors[i].k].totalL += colors[i].l
			clusters[colors[i].k].totalA += colors[i].a
			clusters[colors[i].k].totalB += colors[i].b
		}


		for (let i = 0; i < clusters.length; i++) {
			// Check whether the centroid has changed, and update it if it has
			let changedL = clusters[i].totalL / clusters[i].size
			if(clusters[i].l !== changedL) {
				clusters[i].l = changedL
				foundChanges++
			}
			let changedA = clusters[i].totalA / clusters[i].size
			if (clusters[i].a !== changedA) {
				clusters[i].a = changedA
				foundChanges++
			}
			let changedB = clusters[i].totalB / clusters[i].size
			if (clusters[i].b !== changedB) {
				clusters[i].b = changedB
				foundChanges++
			}

			// Zero out the counters for centroid averages
			clusters[i].size = 0
			clusters[i].totalL = 0
			clusters[i].totalA = 0
			clusters[i].totalB = 0
		}

		totalChanges++

		// Guard against runaway loops
		cycleLimit--
		if(cycleLimit <= 0) {
			console.error("Reached cycle limit")
			break
		}
	}

	console.log(k, "clusters identified in", totalChanges, "passes")

	return clusters
}

function palettize(k) {
	// Calculating k-means on lots of pixels is computationally expensive, so
	// resize down to thumbnail size and then use that for k-means
	let sample = createImage(source.width, source.height)
	sample.copy(source, 0, 0, source.width, source.height, 0, 0, source.width, source.height)
	sample.resize(100, 0)
	sample.loadPixels()

	let colors = []

	console.log("data length", sample.pixels.length/4)
	for (let i = 0 ; i < sample.pixels.length ; i += 4) {
		let r = sample.pixels[i]
		let g = sample.pixels[i+1]
		let b = sample.pixels[i+2]

		colors.push(rgbToLab(r, g, b))
	}

	let clusters = kMeans(colors, k)
	console.log("clusters", clusters)

	rgb = []
	for (let i = 0; i < clusters.length; i++) {
		rgb.push(labToRgb(clusters[i]))
	}

	target = createImage(source.width, source.height)
	target.loadPixels()

	for (let i = 0; i < source.pixels.length; i+= 4) {
		let minDist = Number.MAX_SAFE_INTEGER
		let index = -1
		for(let j = 0; j < rgb.length; j++) {
			let d = dist(
				source.pixels[i], source.pixels[i+1], source.pixels[i+2],
				rgb[j].r, rgb[j].g, rgb[j].b)
			if (d < minDist) {
				minDist = d
				index = j
			}
		}

		target.pixels[i] = rgb[index].r
		target.pixels[i+1] = rgb[index].g
		target.pixels[i+2] = rgb[index].b
		target.pixels[i+3] = 255
	}

	target.updatePixels()
}

function preload() {
	image1 = loadImage("/images/motorcycle.jpg")
	image2 = loadImage("/images/dog.jpg")
}

function setup() {
	chrisDefaults()
	colorMode(RGB)

	image1.resize(600, 0)
	image1.loadPixels()

	image2.resize(0, 600)
	image2.loadPixels()

	source = image1

	createCanvas(source.width, source.height, P2D, canvas)

	let slider = new NativeSlider("Colors in color palette", 2, 16, 1, 8, (event, slider) => {
		palettize(slider.value);
	}, null)

	new NativeButton("Show Palettized Image", (event) => {
		mode = "target"
	})

	new NativeButton("Show Original Image", (event) => {
		mode = "source"
	})

	new NativeButton("Show Palette Colors", (event) => {
		mode = "palette"
	})

	new NativeButton("Dog Picture", (event) => {
		source = image2
		resizeCanvas(source.width, source.height)
		palettize(slider.value);
	})

	new NativeButton("Motorcycle Picture", (event) => {
		source = image1
		resizeCanvas(source.width, source.height)
		palettize(slider.value);
	})

	palettize(8)
}

function draw() {
	if (mode === "target") {
		image(target, 0, 0)
	} else if (mode === "source") {
		image(source, 0, 0)
	} else if (mode === "palette") {
		background(255)
		for(let i = 0; i < rgb.length; i++) {
			noStroke()
			fill(rgb[i].r, rgb[i].g, rgb[i].b)
			rect(60*(i%4), 60*Math.floor(i/4), 50, 50)
		}
	}
}

You may use all or part of the p5 code seen on this page for your own creations, without restriction. Attribution is appreciated but not required.

This sketch also includes the following sources: p5common.js . Sources are available under their respective licenses.

See all p5.js sketches