- BrainTools - https://www.braintools.ru -

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

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

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

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

Соответственно, чтобы решить нашу задачу, нам требуется автоматически:
Определять размер пикселя
Находить оптимальную сетку
Формировать ограниченную палитру
Даунскейлить без потерь
Финально очищать от шума и артефактов
Здесь я использую edge‑aware detection: анализ градиентов Собеля [3] + голосование по тайлам.
Шаг 1: Выбор информативных тайлов
Разбиваю картинку на 3 × 3 и беру тайлы с высокой дисперсией.
Шаг 2: Поиск границ через фильтр Собеля
Фильтр Собеля позволяет найти места с резкими переходами цветов — то есть потенциальные границы между псевдопикселями.
Мы получаем двумерную карту “резкости” по X и Y, где яркие линии — это места, где картинка притворяется пиксельной.
Шаг 3: Генерация профиля
Теперь мы превращаем 2D-гистограмму границ в 1D-профиль: суммируем яркость по каждому столбцу (для X) и строке (для Y). Это даёт нам наглядную кривую, где пики указывают на предполагаемые линии сетки.
Шаг 4: Выбор масштаба через голосование
Далее рассмотрим распределение расстояний между пиками. В большинстве тестовых картинок всплывает шаг 8/12/16/24 px; 43 px — просто хороший «демо‑случай».
Для наглядности мы визуализируем границы между “псевдопикселями” — то есть места с наибольшими цветовыми переходами по всей картинке. Это помогает убедиться, что структура действительно регулярная, а выбранный масштаб соответствует реальному «рисунку» нейросети.
Итак, мы научились определять размер сетки, в которую нейросеть пыталась уложить «пиксели». В нашем случае — это 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 без остатка, и каждый блок внутри сетки стал честной «ячейкой», которую можно анализировать и уменьшать без искажений.
Настоящий ретро‑пиксель‑арт — это всегда ещё и палитра. Например, в NES было 54 отображаемых цвета (из 64) и максимум 4 на тайл; в SNES — больше, но всё равно жёстко фиксировано.
Поэтому настоящая пиксельная графика — это всегда не только про сетку, но и про сдержанную палитру.
Для нас ограничение палитры решает сразу несколько задач:
Убирает шум — плавные градиенты, антиалиасинг, случайные оттенки.
Приближает к эстетике ретро — делает картинку «собранной» и аккуратной, как если бы её действительно рисовали для GBA или Mega Drive.
Также это позволяет нам проще подогнать спрайт к остальным ассетам нашей игры, если мы используем единую палитру, например, из lospec.com [4]
Для формирования палитры я использовал квантизацию через алгоритм WuQuant из библиотеки image-q [5]
Квантизация — это процесс сведения похожих цветов к ближайшему из фиксированной палитры.
Если в изображении было, скажем, 1500 зелёных оттенков, то после квантизации останется 4–8, и каждый пиксель будет перекрашен в ближайший.
Для каждого блока (например, 43×43 пикселя) мы проводим голосование:
Выделяем блок — берём участок, соответствующий одному пикселю в финальной картинке
Считаем цвета — сколько раз встречается каждый
Выбираем победителя — если один цвет встречается чаще остальных (больше 5% от всех), он и становится цветом блока
Если голосование не определило явного победителя, берём средний цвет (с усреднением по RGB)

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


Опробовать инструмент самостоятельно можно тут [6]
Исходный код библиотеки и тула можно найти на Github [7]
Bonus: если тебе понравилась статья, возможно будет интересно подписаться на мой Telegram [8]
Автор: jenissimo
Источник [9]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/17585
URLs in this post:
[1] боль: http://www.braintools.ru/article/9901
[2] латентным представлением: https://habr.com/ru/articles/807405/
[3] анализ градиентов Собеля: https://ru.wikipedia.org/wiki/%D0%9E%D0%BF%D0%B5%D1%80%D0%B0%D1%82%D0%BE%D1%80_%D0%A1%D0%BE%D0%B1%D0%B5%D0%BB%D1%8F
[4] lospec.com: http://lospec.com
[5] image-q: https://github.com/ibezkrovnyi/image-quantization
[6] тут: https://jenissimo.itch.io/unfaker
[7] Github: https://github.com/jenissimo/unfake.js
[8] Telegram: https://t.me/ai_madness_diary
[9] Источник: https://habr.com/ru/articles/930462/?utm_campaign=930462&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.