Skip to content

Palette from Image — Go source

Extract the dominant colors from any image as a reusable palette — median-cut quantization with population shares, hex and rgb, copyable — runs entirely in your browser.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package paletteextract is the Go twin of CosmoDev's src/lib/palette-extract.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic: median-cut quantization over RGBA pixels
// using stable sorts only. The table-driven tests in palette-from-image_test.go
// share vectors with src/lib/palette-extract.test.ts so the two implementations
// are held to the same contract.
package paletteextract

import (
	"fmt"
	"math"
	"sort"
)

// Swatch is one quantized palette color plus the number of sampled pixels it
// covers. Mirrors the TS Swatch interface.
type Swatch struct {
	R, G, B    uint8
	Population int
}

// pixel is an opaque sample pixel (alpha-0 pixels are dropped at sampling).
type pixel struct {
	r, g, b uint8
}

// maxSamples down-samples so large images quantize in bounded time.
const maxSamples = 16384

// ExtractPalette runs median-cut quantization over an RGBA byte buffer.
// maxColors bounds the palette size (the TS default is 8). It returns nil for
// empty or fully transparent input, and never panics.
func ExtractPalette(rgba []byte, maxColors int) []Swatch {
	total := len(rgba) / 4
	if total == 0 {
		return nil
	}

	pixels := make([]pixel, 0, total)
	stride := max(1, total/maxSamples)
	for i := 0; i < total; i += stride {
		o := i * 4
		if rgba[o+3] == 0 {
			continue // fully transparent pixels never contribute
		}
		pixels = append(pixels, pixel{r: rgba[o], g: rgba[o+1], b: rgba[o+2]})
	}
	if len(pixels) == 0 {
		return nil
	}

	buckets := [][]pixel{pixels}
	for len(buckets) < maxColors {
		// Widest-range bucket with more than one distinct value wins the split.
		// Strict > keeps the lowest-index bucket on ties, as in the TS loop.
		bestIdx := -1
		bestRange := 1 // range 1 (exact duplicates only) never splits further
		for i := range buckets {
			if r := channelRange(buckets[i]); r > bestRange {
				bestRange = r
				bestIdx = i
			}
		}
		if bestIdx == -1 {
			break
		}
		bucket := buckets[bestIdx]
		// Mirror the TS splice(bestIdx, 1) + push(halves): remove, then append.
		buckets = append(buckets[:bestIdx], buckets[bestIdx+1:]...)
		lo, hi := splitBucket(bucket)
		buckets = append(buckets, lo, hi)
	}

	swatches := make([]Swatch, 0, len(buckets))
	for _, bucket := range buckets {
		if len(bucket) == 0 {
			continue
		}
		var sr, sg, sb int
		for _, p := range bucket {
			sr += int(p.r)
			sg += int(p.g)
			sb += int(p.b)
		}
		swatches = append(swatches, Swatch{
			R:          roundAvg(sr, len(bucket)),
			G:          roundAvg(sg, len(bucket)),
			B:          roundAvg(sb, len(bucket)),
			Population: len(bucket),
		})
	}
	// Population-descending, stable — mirrors the TS .sort(b - a) comparator.
	sort.SliceStable(swatches, func(i, j int) bool {
		return swatches[i].Population > swatches[j].Population
	})
	return swatches
}

// ToHex formats a swatch as #rrggbb (lowercase, always two digits per channel).
func ToHex(sw Swatch) string {
	return fmt.Sprintf("#%02x%02x%02x", sw.R, sw.G, sw.B)
}

// roundAvg averages a channel sum over n with TS Math.round semantics
// (half rounds up; sums are non-negative so math.Round matches exactly).
func roundAvg(sum, n int) uint8 {
	return uint8(math.Round(float64(sum) / float64(n)))
}

// channelRange returns the widest per-channel min..max span in the bucket.
func channelRange(bucket []pixel) int {
	minR, maxR := uint8(255), uint8(0)
	minG, maxG := uint8(255), uint8(0)
	minB, maxB := uint8(255), uint8(0)
	for _, p := range bucket {
		minR, maxR = min(minR, p.r), max(maxR, p.r)
		minG, maxG = min(minG, p.g), max(maxG, p.g)
		minB, maxB = min(minB, p.b), max(maxB, p.b)
	}
	return max(int(maxR-minR), int(maxG-minG), int(maxB-minB))
}

// splitBucket sorts the bucket by its widest channel (ties prefer r, then g —
// mirroring the TS reduce) and halves it at the median.
func splitBucket(bucket []pixel) ([]pixel, []pixel) {
	minR, maxR := uint8(255), uint8(0)
	minG, maxG := uint8(255), uint8(0)
	minB, maxB := uint8(255), uint8(0)
	for _, p := range bucket {
		minR, maxR = min(minR, p.r), max(maxR, p.r)
		minG, maxG = min(minG, p.g), max(maxG, p.g)
		minB, maxB = min(minB, p.b), max(maxB, p.b)
	}
	ranges := [3]int{int(maxR - minR), int(maxG - minG), int(maxB - minB)}
	ch := 0 // r
	if ranges[1] > ranges[ch] {
		ch = 1 // g
	}
	if ranges[2] > ranges[ch] {
		ch = 2 // b
	}

	sort.SliceStable(bucket, func(i, j int) bool {
		switch ch {
		case 0:
			return bucket[i].r < bucket[j].r
		case 1:
			return bucket[i].g < bucket[j].g
		default:
			return bucket[i].b < bucket[j].b
		}
	})
	mid := len(bucket) / 2
	return bucket[:mid], bucket[mid:]
}

Also available in 9 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →