
За последние пару лет генеративные нейросети стали волшебной кисточкой для всего: концепт‑артов, иконок, иллюстраций, обложек, аватаров, спрайтов… Особенно — пиксель‑арта. В Midjourney, Stable Diffusion, Dall‑E, Image-1 и в других моделях можно просто вбить: «Pixel art goose with goggles in the style of SNES» — и получить шикарного пиксельного гуся за 10 секунд.
Но если ты пробовал вставить такого гуся в игру — ты уже знаешь боль.
Я решил вкопаться в эту тему поглубже и сделать open‑source‑инструмент, который автоматизирует превращение AI‑generated pixel art в pixel‑perfect pixel art.
В конце статьи вас ждут ссылки на исходный код и сам инструмент, а пока пройдемся по матчасти.
Введение
Нейросеть не знает, что такое «пиксель». Современные модели, вроде Stable Diffusion, работают не с сеткой пикселей напрямую, а с латентным представлением изображения в виде непрерывного шума. Они начинают с «тумана» и шаг за шагом приближаются к финальной картинке, добавляя детали, формы, цвета — но всё это происходит в непрерывном пространстве, где нет понятия дискретной сетки или фиксированной палитры.
Рассмотрим типичный AI Pixel Art:

Во-первых, у него неровная сетка

Во-вторых, даже если мы выровняем сетку — мы увидим, что не все пиксели идеально ложатся в нее

В-третьих, если мы визуализируем нашу палитру цветов, мы получим следующее:

Как следствие, при попытке даунскейла через nearest neighbor мы получим примерно следующее

Соответственно, чтобы решить нашу задачу, нам требуется автоматически:
Определять размер пикселя
Находить оптимальную сетку
Формировать ограниченную палитру
Даунскейлить без потерь
Финально очищать от шума и артефактов
Этап 1: Поиск масштаба псевдо-пикселя (Scale Detection)
Здесь я использую edge‑aware detection: анализ градиентов Собеля + голосование по тайлам.
Шаг 1: Выбор информативных тайлов
Разбиваю картинку на 3×3 и беру тайлы с высокой дисперсией.

Шаг 2: Поиск границ через фильтр Собеля
Фильтр Собеля позволяет найти места с резкими переходами цветов — то есть потенциальные границы между псевдопикселями.

Мы получаем двумерную карту «резкости» по X и Y, где яркие линии — это места, где картинка притворяется пиксельной.
Шаг 3: Генерация профиля
Теперь мы превращаем 2D-гистограмму границ в 1D-профиль: суммируем яркость по каждому столбцу (для X) и строке (для Y). Это даёт нам наглядную кривую, где пики указывают на предполагаемые линии сетки.

Шаг 4: Выбор масштаба через голосование
Далее рассмотрим распределение расстояний между пиками. В большинстве тестовых картинок всплывает шаг 8/12/16/24 px; 43 px — просто хороший «демо‑случай».

Для наглядности мы визуализируем границы между «псевдопикселями» — то есть места с наибольшими цветовыми переходами по всей картинке. Это помогает убедиться, что структура действительно регулярная, а выбранный масштаб соответствует реальному «рисунку» нейросети.

Этап 2: Выравнивание сетки и умная обрезка
Итак, мы научились определять размер сетки, в которую нейросеть пыталась уложить «пиксели». В нашем случае — это 43×43 пикселя. Но одного масштаба недостаточно. Нам нужно понять, с какой точки сетку начинать, чтобы она совпадала с содержимым картинки. Сделаем это через следующий алгоритм:
Преобразуем изображение в градации серого.
Считаем границы — места, где изображение резко меняется, с помощью фильтра Собеля.
Строим профили по строкам и столбцам — сколько «резкости» приходится на каждую линию.
Перебираем все возможные сдвиги от 0 до scale — 1, и на каждом шаге оцениваем, насколько хорошо сетка совпадает с пиками в этих профилях.
Выбираем такой сдвиг (x, y), при котором сетка максимально точно ложится на изображение.
function findOptimalCrop(grayMat, scale, cv) {
const sobelX = new cv.Mat();
const sobelY = new cv.Mat();
try {
cv.Sobel(grayMat, sobelX, cv.CV_32F, 1, 0, 3);
cv.Sobel(grayMat, sobelY, cv.CV_32F, 0, 1, 3);
const profileX = new Float32Array(grayMat.cols).fill(0);
const profileY = new Float32Array(grayMat.rows).fill(0);
const dataX = sobelX.data32F;
const dataY = sobelY.data32F;
for (let y = 0; y < grayMat.rows; y++) {
for (let x = 0; x < grayMat.cols; x++) {
const idx = y * grayMat.cols + x;
profileX[x] += Math.abs(dataX[idx]);
profileY[y] += Math.abs(dataY[idx]);
}
}
const findBestOffset = (profile, s) => {
let bestOffset = 0, maxScore = -1;
for (let offset = 0; offset < s; offset++) {
let currentScore = 0;
for (let i = offset; i < profile.length; i += s) {
currentScore += profile[i] || 0;
}
if (currentScore > maxScore) {
maxScore = currentScore;
bestOffset = offset;
}
}
return bestOffset;
};
const bestDx = findBestOffset(profileX, scale);
const bestDy = findBestOffset(profileY, scale);
logger.log(`Optimal crop found: x=${bestDx}, y=${bestDy}`);
return { x: bestDx, y: bestDy };
} finally {
sobelX.delete();
sobelY.delete();
}
}
В результате мы знаем не только размер псевдопикселя, но и его положение — то есть можем с уверенностью нарисовать сетку, которая совпадает с визуальной структурой изображения.
После этого картинку можно обрезать так, чтобы её размеры делились на 43 без остатка, и каждый блок внутри сетки стал честной «ячейкой», которую можно анализировать и уменьшать без искажений.
Этап 3. Формирование палитры
Настоящий ретро‑пиксель‑арт — это всегда ещё и палитра. Например, в NES было 54 отображаемых цвета (из 64) и максимум 4 на тайл; в SNES — больше, но всё равно жёстко фиксировано.
Поэтому настоящая пиксельная графика — это всегда не только про сетку, но и про сдержанную палитру.
Для нас ограничение палитры решает сразу несколько задач:
Убирает шум — плавные градиенты, антиалиасинг, случайные оттенки.
Приближает к эстетике ретро — делает картинку «собранной» и аккуратной, как если бы её действительно рисовали для GBA или Mega Drive.
Также это позволяет нам проще подогнать спрайт к остальным ассетам нашей игры, если мы используем единую палитру, например, из lospec.com
Для формирования палитры я использовал квантизацию через алгоритм WuQuant из библиотеки image-q
Квантизация — это процесс сведения похожих цветов к ближайшему из фиксированной палитры.
Если в изображении было, скажем, 1500 зелёных оттенков, то после квантизации останется 4–8, и каждый пиксель будет перекрашен в ближайший.

Этап 4. Даунскейл по доминирующему цвету
Для каждого блока (например, 43×43 пикселя) мы проводим голосование:
Выделяем блок — берём участок, соответствующий одному пикселю в финальной картинке
Считаем цвета — сколько раз встречается каждый
Выбираем победителя — если один цвет встречается чаще остальных (больше 5% от всех), он и становится цветом блока
Если голосование не определило явного победителя, берём средний цвет (с усреднением по RGB)


В отличие от наивного рескейла (где цвета смешиваются и рождают артефакты), такой блоковый подход делает каждый пиксель осмысленным.
Итог
В результате мы получаем чистое pixel-art изображение

Надо заметить, что не со всеми картинками результат получается одинаково хорошим, в некоторых случаях требуется немного поиграть настройками и доработать напильником пиксельным редактором.
Другие примеры




Опробовать инструмент самостоятельно можно тут
Исходный код библиотеки и тула можно найти на Github
Bonus: если тебе понравилась статья, возможно будет интересно подписаться на мой Telegram.
VitalyZaborov
Полезный инструмент, спасибо!