Skip to content

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

// Pure palette extraction — median-cut quantization over RGBA pixels.
// No React, no DOM; deterministic (stable sorts only).

export interface Swatch {
  r: number;
  g: number;
  b: number;
  population: number;
}

interface Pixel {
  r: number;
  g: number;
  b: number;
}

/** Down-sample so large images quantize in bounded time. */
const MAX_SAMPLES = 16384;

/** Median-cut quantization: split the widest channel at the median, repeat. */
export function extractPalette(rgba: Uint8Array, maxColors = 8): Swatch[] {
  const total = Math.floor(rgba.length / 4);
  if (total === 0) return [];

  const pixels: Pixel[] = [];
  const stride = Math.max(1, Math.floor(total / MAX_SAMPLES));
  for (let i = 0; i < total; i += stride) {
    const o = i * 4;
    if (rgba[o + 3] === 0) continue;
    pixels.push({ r: rgba[o], g: rgba[o + 1], b: rgba[o + 2] });
  }
  if (pixels.length === 0) return [];

  let buckets: Pixel[][] = [pixels];
  while (buckets.length < maxColors) {
    // Widest-range bucket with more than one distinct value wins the split.
    let bestIdx = -1;
    let bestRange = 1; // Range 1 (exact duplicates only) never splits further.
    for (let i = 0; i < buckets.length; i++) {
      const range = channelRange(buckets[i]);
      if (range > bestRange) {
        bestRange = range;
        bestIdx = i;
      }
    }
    if (bestIdx === -1) break;
    const bucket = buckets.splice(bestIdx, 1)[0];
    buckets.push(...splitBucket(bucket));
  }

  return buckets
    .filter((bucket) => bucket.length > 0)
    .map((bucket) => ({
      r: Math.round(bucket.reduce((s, p) => s + p.r, 0) / bucket.length),
      g: Math.round(bucket.reduce((s, p) => s + p.g, 0) / bucket.length),
      b: Math.round(bucket.reduce((s, p) => s + p.b, 0) / bucket.length),
      population: bucket.length,
    }))
    .sort((a, b) => b.population - a.population);
}

function channelRange(bucket: Pixel[]): number {
  let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0;
  for (const p of bucket) {
    if (p.r < minR) minR = p.r;
    if (p.r > maxR) maxR = p.r;
    if (p.g < minG) minG = p.g;
    if (p.g > maxG) maxG = p.g;
    if (p.b < minB) minB = p.b;
    if (p.b > maxB) maxB = p.b;
  }
  return Math.max(maxR - minR, maxG - minG, maxB - minB);
}

function splitBucket(bucket: Pixel[]): Array<Pixel[]> {
  let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0;
  for (const p of bucket) {
    if (p.r < minR) minR = p.r;
    if (p.r > maxR) maxR = p.r;
    if (p.g < minG) minG = p.g;
    if (p.g > maxG) maxG = p.g;
    if (p.b < minB) minB = p.b;
    if (p.b > maxB) maxB = p.b;
  }
  const ranges = { r: maxR - minR, g: maxG - minG, b: maxB - minB };
  const channel = (['r', 'g', 'b'] as const).reduce((a, c) => (ranges[c] > ranges[a] ? c : a), 'r');

  const sorted = [...bucket].sort((a, b) => a[channel] - b[channel]); // stable, deterministic
  const mid = Math.floor(sorted.length / 2);
  return [sorted.slice(0, mid), sorted.slice(mid)];
}

/** Format a swatch as #rrggbb. */
export function toHex({ r, g, b }: Swatch): string {
  return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}

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 →