Я фронтенд-разработчик, в основном работаю с React. И в одном из своих пет-проектов я столкнулся с задачей, что нужно было автоматически извлекать доминирующие цвета из изображения — для генерации палитры. Начал разбираться, какие подходы вообще существуют, как это делают другие инструменты и что из этого можно реализовать прямо в браузере без тяжёлых зависимостей и серверной обработки. В итоге, получился стабильный и визуально адекватный результат даже на больших изображениях.

В этой статье разберём, как это работает — от сырых пикселей до компактной палитры доминирующих цветов.

Итак, любое изображение — это набор пикселей. А каждый пиксель — это цвет.
Если открыть любую фотографию, там легко окажутся десятки тысяч уникальных оттенков.

Просто усреднить всё изображение – получим один грязный цвет, взять самые частые значения — теряем разнообразие. Нужен алгоритм, который учитывает распределение цветов, понимает вес, группирует похожие оттенки и в итоге даёт компактную, но репрезентативную палитру.

Почему нельзя работать с исходным изображением

Первое, что хочется сделать – это взять изображение как есть и прогнать по всем пикселям, но это плохая идея. Обычное фото 4000×3000 — это 12 миллионов пикселей. У каждого пикселя 3 канала (RGB), итого получаем десятки миллионов операций только на чтение, не считая кластеризации.

Если поверх этого запускать k-means — можно легко получить фризы интерфейса, скачки CPU и недовольного пользователя, а у нас ведь всё происходит в браузере.

Поясню кратко, что такое k-means. K-Means — это алгоритм кластеризации, который разбивает набор данных на k групп (кластеров) так, чтобы похожие элементы оказались рядом.

Уменьшение изображения

Берём и перед обработкой сильно уменьшаем изображение. И это тот случай, когда мы почти ничего не теряем, потому что нам не важны мелкие детали, текстуры, отдельные пиксели, нам важно общее распределение цветов.

В коде это выглядит так:

const scale = Math.min(1, maxDim / Math.max(sw, sh));
const w = Math.round(sw * scale);
const h = Math.round(sh * scale);

Где:

  • sw, sh — исходные размеры изображения,

  • maxDim — ограничение (например, 200–300px).

Что это даёт? Допустим, у нас было изображение 4000 × 3000 = 12 000 000 пикселей, а после даунскейла до примерно 220px стало 220 × 165 ≈ 36 000 пикселей. Соответственно, мы теряем детали, но сохраняем распределение цветов и при этом очень сильно облегчаем наши операции по затратам ресурсов.

Цвета в изображении обычно распределены не случайно, а большими областями – небо, земля, кожа, фон, объекты. Поэтому даже после сильного уменьшения пропорции цветов сохраняются, а доминирующие оттенки остаются теми же. Мы оставляем суть изображения, убирая шум.

Отмечу, что даунскейл делается через canvas с включённым сглаживанием:

ctx.imageSmoothingEnabled = true;
ctx.drawImage(source, 0, 0, sw, sh, 0, 0, w, h);

Это дополнительно сглаживает шум и объединяет близкие оттенки.

Сэмплирование пикселей

Даже после даунскейла у нас все еще остается много пикселей. И возникает логичный вопрос: нужно ли обрабатывать их все? – Нет!

Допустим, после уменьшения у нас ~30 000 – 60 000 пикселей. Если каждый отправить в кластеризацию, то, естественно, увеличивается время работы k-means вырастет нагрузка на процессор, а результат почти не меняется, потому что соседние пиксели часто очень похожи друг на друга. Т.е, получается, мы будем считать одно и то же много раз, а нам это не нужно. Соответственно, брать нужно не все, а репрезентативную выборку. Вместо полного перебора мы берём пиксели с шагом:

const stridePx = Math.max(1,Math.floor(Math.sqrt(totalPx / maxSamples)));

Где:

  • totalPx — общее число пикселей

  • maxSamples — желаемое количество выборки (например, 60 000)

и получается, что формула подбирает шаг так, чтобы чем больше изображение, тем больше шаг. Дальше просто идём по изображению с этим шагом:

for (let y = 0; y < height; y += stridePx) {
  for (let x = 0; x < width; x += stridePx) {
    // берём пиксель
  }
}

Да, можно было бы брать случайные пиксели, но это приведет к нестабильному результату, каждый запуск будет давать разный результат. Также можно случайно пропустить важные области, да и сложнее отлаживать. Сетка же детерминирована, предсказуема и равномерно покрывает изображение.

Квантование цветов

У нас все еще остаются тысячи уникальных цветов. И следующая проблема — они слишком «раздроблены». Например, rgb(120, 130, 140), rgb(121, 131, 139), rgb(119, 129, 141) по факту для алгоритма это три разных цвета, но для человека это один и тот же серо-синий оттенок. С этим нужно что-то делать, а если ничего не делать, то кластеризация будет тратить ресурсы на различия, которые мы даже не видим. Здесь нужно «сжать» цветовое пространство. Вместо 256 значений на канал берём меньше.

В коде:

const r5 = r >> 3;
const g5 = g >> 3;
const b5 = b >> 3;

>> 3 — это деление на 8, было: 0…255, стало: 0…31, т.е. 256 значений становится 32-мя на канал. И получается, что полное пространство цветов 256^3 ≈ 16 700 000 цветов, а после квантования 32^3 = 32 768 цветов. Пространство уменьшилось в 500 раз.

Группировка в «корзины»

Каждый пиксель попадает в свою корзину:

const idx = (r5 << 10) | (g5 << 5) | b5;

Это просто способ собрать 3 канала в один ключ. Дальше агрегируем:

const cur = buckets.get(idx);
  if (cur) {
    cur.count++;
    cur.r += r;
    cur.g += g;
    cur.b += b;
  } else {
    buckets.set(idx, { count: 1, r, g, b });
}

Для каждой «корзины» мы считаем count — сколько пикселей туда попало и сумму r, g, b. Позже это превращается в объект вида:

{
  r: agg.r / w,
  g: agg.g / w,
  b: agg.b / w,
  w: count
}

Этими действиями мы сливаем похожие цвета и получаем вместо 100 почти одинаковых оттенков один усреднённый цвет, а также уменьшаем количество точек и добавляем вес цветам. Чем больше пикселей попало в одну корзину, тем цвет важнее.

Да, мы теряем часть информации, но это контролируемая потеря и она не критична. Для задачи поиска доминирующих цветов точность до единичного значения RGB не нужна, зато алгоритм становится быстрее, шум уменьшается и палитра становится стабильнее.

Взвешивание цветов (почему важен weight)

После квантования есть важный нюанс: не все цвета одинаково важны. Проблема в том, что, если представим изображение, например, 70% — небо (синий), 25% — земля (коричневый) и 5% — это мелкие детали. Если просто передать все цвета в k-means как есть, алгоритм будет считать их равными и маленький яркий объект может перетянуть центр кластера, шум начнёт влиять на результат, хотя для человека очевидно, что небо — главный цвет. Нужно учитывать вес цвета. В коде это выглядит так:

type Point = { r: number; g: number; b: number; w: number };

Где:

  • r, g, b — цвет

  • w — вес (сколько пикселей попало в эту корзину)

Откуда берётся weight? Он считается на этапе корзин:

cur.count++;

И потом превращается в:

const w = agg.count;

Чем больше пикселей одного цвета — тем больше его вес.

Как weight влияет на k-means

При пересчёте центров кластеров:

sumR[c] += p.r * w;
sumG[c] += p.g * w;
sumB[c] += p.b * w;
sumW[c] += w;

И затем:

r: sumR[c] / w
g: sumG[c] / w
b: sumB[c] / w

Без веса каждый цвет = 1 голос. А с весом большой цвет — это тысячи голосов, а маленький цвет имеет мало голосов. Допустим, синий цвет неба - 10 000 пикселей, а красный (объект) — 200 пикселей. Без weight оба цвета равны и кластер может «уехать» в красный, а с weight синий доминирует и центр остаётся синим. По итогу мы не просто группируем цвета, учитывая, насколько они важны в изображении.

Кластеризация. Упрощённый K-Means

После всех предыдущих шагов у нас есть компактный набор точек в RGB-пространстве, каждая точка — это цвет, у которой есть вес. При этом количество точек у нас исчисляется сотнями, а не миллионами. Теперь задача сжать это множество в k-репрезентативных цветов.

Алгоритм такой:

  1. Выбираем k-центров (будущие цвета палитры)

  2. Присваиваем каждой точке ближайший центр

  3. Пересчитываем центры

  4. Повторяем несколько раз

Чтобы понять, какой центр ближе, считаем расстояние в RGB:

d = (r1 - r2)^2 + (g1 - g2)^2 + (b1 - b2)^2

В коде:

function dist2(a, b) {
  const dr = a.r - b.r;
  const dg = a.g - b.g;
  const db = a.b - b.b;
  return dr * dr + dg * dg + db * db;
}

Почему без квадратного корня, спросите вы? Потому что sqrt дорогой и нам важен только порядок расстояний, а результат тот же.

Шаг 1: назначение точек кластерам

Для каждой точки ищем ближайший центр:

for (let i = 0; i < points.length; i++) {
  let best = 0;
  let bestD = Infinity;

  for (let c = 0; c < centers.length; c++) {
    const d = dist2(points[i], centers[c]);
    if (d < bestD) {
      bestD = d;
      best = c;
    }
  }

  assign[i] = best;
}

Шаг 2: пересчёт центров

С учётом веса:

sumR[c] += p.r * w;
sumG[c] += p.g * w;
sumB[c] += p.b * w;
sumW[c] += w;

Новый центр:

centers[c] = {
  r: sumR[c] / w,
  g: sumG[c] / w,
  b: sumB[c] / w,
  w,
};

Это обычное среднее, но с весами

Итерации

Алгоритм повторяется несколько раз:

for (let iter = 0; iter < iters; iter++) {
  // assign + update
}

В моем случае примерно 8 итераций, и этого достаточно для хорошего результата. По результатам этих манипуляций получаем: группировку близких цветов, находит «центры» этих групп и отбрасывает мелкие различия.

В итоге каждый центр — это один доминирующий цвет.

Наглядно представить это можно так: у нас есть облако точек в RGB, K-Means «разрезает» его на k областей и получается, что центр каждой области — это цвет палитры.

Именно здесь тысячи цветов превращаются в 5–10 осмысленных.

Как выбираются центры

Первый центр — это самый «тяжёлый» цвет. Логично, что берём самый распространённый цвет.

const sorted = [...points].sort((a, b) => b.w - a.w);
const centers = [{ ...sorted[0] }];

Каждый следующий центр выбирается по принципу: чем дальше от уже выбранных центров и чем больше вес — тем лучше.

let minD = Infinity;
for (const c of centers) {
  minD = Math.min(minD, dist2(p, c));
}

В итоге получаем итоговый скор:

const score = minD * (p.w + 1);

Алгоритм ищет точки, которые далеко от текущих центров и при этом важные (частые). Это даёт хорошее покрытие цветового пространства и отсутствие «слипшихся» кластеров. Без этого шага центры могут оказаться рядом, а часть цветов вообще не попадёт в палитру.

Допустим, есть большой синий участок, средний зелёный и маленький красный. Алгоритм возьмёт синий, потом зелёный (далеко + достаточно весомый) и потом красный (далеко, хоть и маленький). В итоге все важные цвета попадут в палитру.

Удаление похожих цветов

После k-means у нас есть k-центров и вроде бы готовая палитра, но на практике может получиться, что часть цветов слишком похожи друг на друга. Например, #3A6EA5, #3C70A7, #3B6FA6. Формально — они разные. Визуально — один и тот же синий. Даже хороший k-means может разбить одну область на несколько кластеров, особенно если там есть градиенты или шум.

Здесь нужно прибегнуть к фильтрации близких цветов

В коде это выглядит так:

if (dist2(rgb, o) < 18 * 18) {
  ok = false;
}

Где:

  • dist2 — расстояние в RGB

  • 18 — порог похожести

Т.е. мы идём по всем найденным цветам, берём очередной цвет, сравниваем с уже выбранными и, если слишком близко, то пропускаем.

for (const o of outRgb) {
  if (dist2(rgb, o) < threshold) {
    ok = false;
    break;
  }
}

Почему именно расстояние? Мы используем ту же метрику:

d = (r1 - r2)^2 + (g1 - g2)^2 + (b1 - b2)^2

Если расстояние маленькое, значит цвета почти одинаковые. Таким образом, чистим выбранные цвета от дубликатов.

Вот, собственно, и весь алгоритм, нам осталось позаботиться о производительности и здесь нам поможет Web Worker.

Заключение

Если посмотреть на весь процесс целиком, то задача «найти доминирующие цвета» — это не одна магическая формула, а цепочка простых, но правильно выстроенных шагов. Итоговая логика выглядит так:

  • уменьшаем изображение

  • берём репрезентативную выборку пикселей

  • упрощаем цветовое пространство

  • учитываем вес каждого цвета

  • группируем через k-means

  • и в конце чистим результат

Нужно понимать, что доминирующие цвета — это не самые частые пиксели и не среднее по изображению, а выборка цветов, которая поддерживает баланс производительности, логики алгоритма и то, как человек видит изображение. Данную логику я реализовал у себя в проекте, посмотреть можно здесь.

Я не стал приводить весь код в статье, чтобы не перегружать её — здесь разобраны ключевые части и идеи, на которых строится решение.