
Я фронтенд-разработчик, в основном работаю с 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-репрезентативных цветов.
Алгоритм такой:
Выбираем k-центров (будущие цвета палитры)
Присваиваем каждой точке ближайший центр
Пересчитываем центры
Повторяем несколько раз
Чтобы понять, какой центр ближе, считаем расстояние в 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
и в конце чистим результат
Нужно понимать, что доминирующие цвета — это не самые частые пиксели и не среднее по изображению, а выборка цветов, которая поддерживает баланс производительности, логики алгоритма и то, как человек видит изображение. Данную логику я реализовал у себя в проекте, посмотреть можно здесь.
Я не стал приводить весь код в статье, чтобы не перегружать её — здесь разобраны ключевые части и идеи, на которых строится решение.

