В этой статье вы увидите, как сделать генератор ASCII-арта из изображения.

Результат:

но сначала

что такое ASCII-арт?

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

Необходимые условия

Для данного проекта мне хочется применить свои знания JS, поэтому я буду использовать:

npm i sharp readline-sync

Этапы программы:

Когда я думал об ASCII-арте, то представлял, что он создается с помощью какого-то алгоритма детекции краев. Как же я ошибался — для создания ASCII-арта из изображения вам потребуется:

  • превратить изображение в черно-белое;

  • изменить размер изображения;

  • заменить все черно-белые пиксели на символы, определяющие яркость и темноту/тень.

Итак, давайте приступим. Сначала я создам файл package.json, сделав следующее:

npm init

Как только я получу свой пакет, то создам index.js файл, где будет находиться мой код.

Когда это будет сделано, я импортирую все зависимости, необходимые для этого проекта, следующим образом:

const sharp = require("sharp");
const readlineSync = require("readline-sync");
const fs = require("fs");

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

Получение пользовательского ввода

Для этого я создам функцию loadFileFromPath и в ней буду получать данные от пользователя следующим образом:

var filePath = readlineSync.question("What's the file path ");

Зачем нам нужен readlineSync?

Вероятно, вам интересно, что представляет собой пакет readlineSync. Он позволяет нам вводить данные в консоль синхронно; поскольку JS-узел является асинхронным, код продолжает выполняться, поэтому мы используем пакет для того, чтобы дождаться ввода данных пользователем.

Далее я проверю, корректен ли путь или нет, с помощью операторов try/catch, как здесь:

try {
    const file = await sharp(filePath);
    return file;
  } catch (error) {
    console.error(error);
  }

и вся функция выглядит следующим образом:

const loadFileFromPath = async () => {
  var filePath = readlineSync.question("What's the file path ");
  try {
    const file = await sharp(filePath);
    return file;
  } catch (error) {
    console.error(error);
  }
};

Преобразование в черно-белое

Для этого я сначала создам функцию convertToGrayscale с таким параметром пути, как здесь:

const convertToGrayscale = async (path) => {
  // code
};

В этой функции я загружу изображение, изменю его цветовые значения на черно-белые и, наконец, верну черно-белый результат.

const convertToGrayscale = async (path) => {
 const img = await path; 
 const bw = await img.gamma().greyscale();
 return bw;
};

Изменение размера изображения

Для этого я сначала создам функцию resizeImg с параметрами bw и newWidth = 100 следующим образом:

const resizeImg = async (bw, newWidth = 100) => {
  //code
};

Затем я буду ждать ч/б изображение и ожидать результат переменной blackAndWhite, потом получу метаданные для доступа к свойствам размеров.

const resizeImg = async (bw, newWidth = 100) => {
  const blackAndWhite = await bw;
  const size = await blackAndWhite.metadata();
};

далее вычисляем пропорции изображения, для этого просто делим ширину на высоту и получаем необходимое соотношение. Затем мы рассчитываем нашу новую высоту:

const ratio = size.width / size.height;
newHeight = parseInt(newWidth * ratio);

Потом мы окончательно изменяем размер изображения и возвращаем его в таком виде:

const resized = await blackAndWhite.resize(newWidth, newHeight, {
    fit: "outside",
  });
return resized;

Вся функция должна выглядеть следующим образом:

const resizeImg = async (bw, newWidth = 100) => {
  const blackAndWhite = await bw;
  const size = await blackAndWhite.metadata();
  const ratio = size.width / size.height;
  newHeight = parseInt(newWidth * ratio);
  const resized = await blackAndWhite.resize(newWidth, newHeight, {
    fit: "outside",
  });

  return resized;
};

Преобразование пикселей в ASCII-символы 

Для этого я сначала создам функцию pixelToAscii с параметром img следующим образом:

const pixelToAscii = async (img) => {
 //code
};

Затем я создам переменную для хранения img с ключевым словом await. Потом получу массив пикселей изображения и сохраню его в переменной pixels.

var newImg = await img;
const pixels = await newImg.raw().toBuffer();
};

Дальше создам переменную characters, которая будет содержать пустую строку. Затем я пройдусь по каждому пикселю из массива и введу ASCII-символ в созданную ранее строку:

characters = "";
pixels.forEach((pixel) => {
    characters = characters + ASCII_CHARS[Math.floor(pixel * interval)];
  });

Вы можете заметить две глобальные переменные, которые еще не упоминались:

  • interval

  • ASCII_CHARS

Я объясню вам, что они из себя представляют:

  • ASCII_CHARS — это переменная, в которой хранятся все ASCII-символы:

ASCII_CHARS = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. ".split(
  ""
);
  • interval — это ascii, который должен быть присвоен цвету (интенсивность).

charLength = ASCII_CHARS.length;
interval = charLength / 256;

Теперь мы знаем, что это за переменные, давайте вернемся к функции, сейчас она должна выглядеть следующим образом:

const pixelToAscii = async (img) => {
  var newImg = await img;
  const pixels = await newImg.raw().toBuffer();
  characters = "";
  pixels.forEach((pixel) => {
    characters = characters + ASCII_CHARS[Math.floor(pixel * interval)];
  });
  return characters;
};

Теперь все шаги сделаны, давайте создадим ядро приложения:

Главная функция

Для этого я сначала создам функцию main с параметрами newWidth = 100 следующим образом:

const main = async (newWidth = 100) => {
  //code
};

В этой функции я создам функцию с названием: *newImgData, которая будет равна всем тем функциям, которые мы создали ранее, вложенным следующим образом:

const main = async (newWidth = 100) => {
  const newImgData = await pixelToAscii(
    resizeImg(convertToGrayscale(loadFileFromPath()))
  );
};

Затем я выясню длину моих символов и создам пустую переменную с именем ASCII следующим образом:

const pixels = newImgData.length;
let ASCII = "";

Потом переберу список пикселей:

for (i = 0; i < pixels; i += newWidth) {
    let line = newImgData.split("").slice(i, i + newWidth);
    ASCII = ASCII + "\n" + line;
  }

По существу, я делаю разбиение на строки. Получаю размер newWidth, нарезаю массив как строку этой newWidth и затем добавляю символ \n для перехода к следующей строке.

Экспорт в текстовый файл

И, наконец, в той же функции для сохранения текста в файл я сделал следующее:

setTimeout(() => {
    fs.writeFile("output.txt", ASCII, () => {
      console.log("done");
    });
  }, 5000);

В результате мы получили ASCII-арт генератор из изображения! И, конечно же, не забудьте про main() для первого вызова функции.

Законченный код должен выглядеть следующим образом:

const sharp = require("sharp");
const readlineSync = require("readline-sync");
const fs = require("fs");

ASCII_CHARS = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. ".split(
  ""
);
charLength = ASCII_CHARS.length;
interval = charLength / 256;
var newHeight = null;
const main = async (newWidth = 100) => {
  const newImgData = await pixelToAscii(
    resizeImg(convertToGrayscale(loadFileFromPath()))
  );
  const pixels = newImgData.length;
  let ASCII = "";
  for (i = 0; i < pixels; i += newWidth) {
    let line = newImgData.split("").slice(i, i + newWidth);
    ASCII = ASCII + "\n" + line;
  }

  setTimeout(() => {
    fs.writeFile("output.txt", ASCII, () => {
      console.log("done");
    });
  }, 5000);
};

const convertToGrayscale = async (path) => {
  const img = await path;
  const bw = await img.gamma().greyscale();
  return bw;
};

const resizeImg = async (bw, newWidth = 100) => {
  const blackAndWhite = await bw;
  const size = await blackAndWhite.metadata();
  const ratio = size.width / size.height;
  newHeight = parseInt(newWidth * ratio);
  const resized = await blackAndWhite.resize(newWidth, newHeight, {
    fit: "outside",
  });

  return resized;
};

const pixelToAscii = async (img) => {
  var newImg = await img;
  const pixels = await newImg.raw().toBuffer();
  characters = "";
  pixels.forEach((pixel) => {
    characters = characters + ASCII_CHARS[Math.floor(pixel * interval)];
  });
  return characters;
};

const loadFileFromPath = async () => {
  var filePath = readlineSync.question("What's the file path ");
  try {
    const file = await sharp(filePath);
    return file;
  } catch (error) {
    console.error(error);
  }
};
main();

Чему я научился в ходе работы над этим проектом?

Этот проект был очень интересным. Я впервые обнаружил, что можно вложить функции, также выяснил, как работает ASCII-арт, узнал об асинхронной проблеме js-узла для пользовательского ввода и о том, как ее решить, и, наконец, как сделать некоторые простые манипуляции с изображениями.


Анимации на сайте давно перестали быть каким-то ноу-хау. Это неотъемлемая часть любого интерфейса. Скоро в OTUS пройдет открытый урок, на котором разберем основы, необходимые для работы с анимацией, и создадим анимированный приветственный экран приложения. Регистрация по ссылке.

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


  1. Pab10
    09.03.2022 13:15

    Интересно, как рассчитывалась яркость каждого символа?


    1. Inspector-Due
      09.03.2022 14:29

      Это делается, когда происходит конвертация из RGB в grayscale.

      Конкретно в той библиотеке это сделано таким образом. На SO есть объяснение


      1. Pab10
        09.03.2022 15:25

        Не пиксела, ASCII-символа. Строка ASCII_CHARS составлена таким образом, что первые символы самые темные, последние — самые светлые. Вот интересно, как это было подобрано / рассчитано. За ссылки с разъяснениями спасибо.


        1. Inspector-Due
          09.03.2022 18:48
          +1

          Я попытался измерить "яркость" каждого символа, но у меня получилась другая последовательность @MBW0Q8#O$&UqpbdmXa%kZhwoI[]unC}1{YJftzxjLvcl?|<>i)(/+!*r;~^":,-_'`

          Как я измерял: брал каждый символ, рисовал его, далее переводил в grayscale, далее суммировал значение всех пикселей. Чем больше — тем ярче и наоборот. Я думаю, проблема в том, что я как-то неправильно рисую. Или, может быть, что все эти символы подбирались эмпирическим путём.

          Вот результаты эксперимента: https://imgur.com/a/Ue8HEuB

          Первый скриншот — "палитра" этой статьи, а второй — моя "палитра"