За последние пару лет генеративные нейросети стали волшебной кисточкой для всего: концепт‑артов, иконок, иллюстраций, обложек, аватаров, спрайтов… Особенно — пиксель‑арта. В 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. Определять размер пикселя

  2. Находить оптимальную сетку

  3. Формировать ограниченную палитру

  4. Даунскейлить без потерь

  5. Финально очищать от шума и артефактов

Этап 1: Поиск масштаба псевдо-пикселя (Scale Detection)

Здесь я использую edge‑aware detection: анализ градиентов Собеля + голосование по тайлам.

Шаг 1: Выбор информативных тайлов

Разбиваю картинку на 3×3 и беру тайлы с высокой дисперсией.

 Пример: тайл 150×150, выбранный за высокую детализацию
Пример: тайл 150×150, выбранный за высокую детализацию

Шаг 2: Поиск границ через фильтр Собеля

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

 Вертикальные границы (Sobel X)  и  Горизонтальные границы (Sobel Y)
Вертикальные границы (Sobel X) и Горизонтальные границы (Sobel Y)

Мы получаем двумерную карту «резкости» по X и Y, где яркие линии — это места, где картинка притворяется пиксельной.

Шаг 3: Генерация профиля

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

 Горизонтальный профиль с отмеченными пиками — предположительными границами «пикселей».
Горизонтальный профиль с отмеченными пиками — предположительными границами «пикселей».

Шаг 4: Выбор масштаба через голосование

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

 Анализ всей картинки: сотни измерений показывают доминирующий шаг — 43 пикселя.
Анализ всей картинки: сотни измерений показывают доминирующий шаг — 43 пикселя.

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

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

Этап 2: Выравнивание сетки и умная обрезка

Итак, мы научились определять размер сетки, в которую нейросеть пыталась уложить «пиксели». В нашем случае — это 43×43 пикселя. Но одного масштаба недостаточно. Нам нужно понять, с какой точки сетку начинать, чтобы она совпадала с содержимым картинки. Сделаем это через следующий алгоритм:

  1. Преобразуем изображение в градации серого.

  2. Считаем границы — места, где изображение резко меняется, с помощью фильтра Собеля.

  3. Строим профили по строкам и столбцам — сколько «резкости» приходится на каждую линию.

  4. Перебираем все возможные сдвиги от 0 до scale — 1, и на каждом шаге оцениваем, насколько хорошо сетка совпадает с пиками в этих профилях.

  5. Выбираем такой сдвиг (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, и каждый пиксель будет перекрашен в ближайший.

Чистая квантизированная палитра из 16 цветов
Чистая квантизированная палитра из 16 цветов

Этап 4. Даунскейл по доминирующему цвету

Для каждого блока (например, 43×43 пикселя) мы проводим голосование:

  1. Выделяем блок — берём участок, соответствующий одному пикселю в финальной картинке

  2. Считаем цвета — сколько раз встречается каждый

  3. Выбираем победителя — если один цвет встречается чаще остальных (больше 5% от всех), он и становится цветом блока

  4. Если голосование не определило явного победителя, берём средний цвет (с усреднением по RGB)

Демо-блок
Демо-блок

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

Итог

В результате мы получаем чистое pixel-art изображение

Финальный спрайт 43x43 пикселя, сетка добавлена для наглядности
Финальный спрайт 43x43 пикселя, сетка добавлена для наглядности

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

Другие примеры

Здесь отключена привязка к сетке
Здесь отключена привязка к сетке
Здесь вручную подобран размер палитры чтобы помочь избавиться от ненужных цветов
Здесь вручную подобран размер палитры чтобы помочь избавиться от ненужных цветов

Опробовать инструмент самостоятельно можно тут

Исходный код библиотеки и тула можно найти на Github

Bonus: если тебе понравилась статья, возможно будет интересно подписаться на мой Telegram.

Комментарии (7)


  1. VitalyZaborov
    23.07.2025 16:24

    Полезный инструмент, спасибо!


  1. Rive
    23.07.2025 16:24

    Похоже, этот алгоритм слабее искажает картинку, чем нода Pixelize в Comfy.