Skip to content

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

# Palette from Image — median-cut quantization over RGBA pixels.
#
# Language: Ruby (3.1+), 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.

Swatch = Struct.new(:r, :g, :b, :population)

# Down-sample so large images quantize in bounded time (TS: MAX_SAMPLES).
MAX_SAMPLES = 16_384

def extract_palette(rgba, max_colors = 8)
  total = rgba.length / 4
  return [] if total.zero?

  pixels = []
  stride = [1, total / MAX_SAMPLES].max
  (0...total).step(stride) do |i|
    o = i * 4
    next if rgba[o + 3].zero?  # fully transparent — skip
    pixels << [rgba[o], rgba[o + 1], rgba[o + 2]]
  end
  return [] if pixels.empty?

  buckets = [pixels]
  while buckets.length < max_colors
    # Widest-range bucket with more than one distinct value splits.
    best_idx = -1
    best = [1, 0]  # range 1 (exact duplicates) never splits
    buckets.each_with_index do |bucket, i|
      range, channel = channel_range(bucket)
      if range > best[0]
        best = [range, channel]
        best_idx = i
      end
    end
    break if best_idx.negative?  # every bucket is uniform — done

    bucket = buckets.delete_at(best_idx)
    channel = best[1]
    # sort_by IS stable in Ruby — the TS lib relies on stable sorts too.
    sorted = bucket.sort_by { |px| px[channel] }
    mid = sorted.length / 2
    buckets << sorted[0, mid]
    buckets << sorted[mid..] || []
  end

  buckets.map { |b|
    n = b.length.to_f
    Swatch.new((b.sum { |px| px[0] } / n).round,
               (b.sum { |px| px[1] } / n).round,
               (b.sum { |px| px[2] } / n).round,
               b.length)
  }.sort_by { |s| -s.population }
end

def channel_range(bucket)
  r_min = g_min = b_min = 255
  r_max = g_max = b_max = 0
  bucket.each do |(r, g, b)|
    r_min = [r_min, r].min; r_max = [r_max, r].max
    g_min = [g_min, g].min; g_max = [g_max, g].max
    b_min = [b_min, b].min; b_max = [b_max, b].max
  end
  r = r_max - r_min; g = g_max - g_min; b = b_max - b_min
  return [r, 0] if r >= g && r >= b
  return [g, 1] if g >= b
  [b, 2]
end

if __FILE__ == $PROGRAM_NAME
  # Smoke: 2 red + 1 blue pixels → red swatch wins on population.
  rgba = [200, 30, 30, 255, 202, 28, 32, 255, 30, 30, 200, 255]
  extract_palette(rgba, 2).each do |s|
    format('#%02X%02X%02X ×%d', s.r, s.g, s.b, s.population).then { puts _1 }
  end
end

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 →