Skip to content

Palette from Image — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Palette from Image — median-cut quantization over RGBA pixels.
//
// Language: Kotlin (1.9+), standard library only
// CosmoDev polyglot showcase port of the `palette-from-image` tool.
// Ported from src/lib/palette-extract.ts — display source, part of CosmoDev's
// polyglot tool pages.
//
// Deterministic: stable sorts only, widest-channel median split, buckets
// average into swatches. Transparent pixels are skipped.

data class Swatch(val r: Int, val g: Int, val b: Int, val population: Int)

private class Pixel(val r: Int, val g: Int, val b: Int)

// Down-sample so large images quantize in bounded time (TS: MAX_SAMPLES).
private const val MAX_SAMPLES = 16384

fun extractPalette(rgba: ByteArray, maxColors: Int = 8): List<Swatch> {
    val total = rgba.size / 4
    if (total == 0) return emptyList()

    val pixels = ArrayList<Pixel>()
    val stride = maxOf(1, total / MAX_SAMPLES)
    var i = 0
    while (i < total) {
        val o = i * 4
        if (rgba[o + 3].toInt() != 0)  // skip fully transparent
            pixels.add(Pixel(rgba[o].toInt() and 0xFF, rgba[o + 1].toInt() and 0xFF, rgba[o + 2].toInt() and 0xFF))
        i += stride
    }
    if (pixels.isEmpty()) return emptyList()

    val buckets = ArrayList<MutableList<Pixel>>(listOf(pixels))
    while (buckets.size < maxColors) {
        // Widest-range bucket with more than one distinct value splits.
        var bestIdx = -1; var bestRange = 1; var bestChannel = 0  // range 1 never splits
        for (k in buckets.indices) {
            val (range, channel) = channelRange(buckets[k])
            if (range > bestRange) { bestRange = range; bestIdx = k; bestChannel = channel }
        }
        if (bestIdx < 0) break  // every bucket is uniform — done

        val bucket = buckets.removeAt(bestIdx)
        val ch = bestChannel
        val sorted = bucket.sortedBy {  // sortedBy IS stable — like the TS lib
            when (ch) { 0 -> it.r; 1 -> it.g; else -> it.b }
        }
        val mid = sorted.size / 2
        buckets.add(ArrayList(sorted.subList(0, mid)))
        buckets.add(ArrayList(sorted.subList(mid, sorted.size)))
    }

    return buckets.map { b ->
        Swatch(
            Math.round(b.sumOf { it.r }.toDouble() / b.size).toInt(),
            Math.round(b.sumOf { it.g }.toDouble() / b.size).toInt(),
            Math.round(b.sumOf { it.b }.toDouble() / b.size).toInt(),
            b.size,
        )
    }.sortedByDescending { it.population }
}

private fun channelRange(bucket: List<Pixel>): Pair<Int, Int> {
    var rMin = 255; var rMax = 0; var gMin = 255; var gMax = 0; var bMin = 255; var bMax = 0
    for (p in bucket) {
        rMin = minOf(rMin, p.r); rMax = maxOf(rMax, p.r)
        gMin = minOf(gMin, p.g); gMax = maxOf(gMax, p.g)
        bMin = minOf(bMin, p.b); bMax = maxOf(bMax, p.b)
    }
    val r = rMax - rMin; val g = gMax - gMin; val b = bMax - bMin
    return if (r >= g && r >= b) r to 0 else if (g >= b) g to 1 else b to 2
}

fun main() {
    // Smoke: 2 red + 1 blue pixels → red swatch wins on population.
    val rgba = byteArrayOf(200.toByte(), 30, 30, 255.toByte(),
        202.toByte(), 28, 32, 255.toByte(), 30, 30, 200.toByte(), 255.toByte())
    for (s in extractPalette(rgba, 2))
        println("#%02X%02X%02X ×%d".format(s.r, s.g, s.b, s.population))
}

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 →