В этой статье вы увидите, как сделать генератор 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 пройдет открытый урок, на котором разберем основы, необходимые для работы с анимацией, и создадим анимированный приветственный экран приложения. Регистрация по ссылке.
Pab10
Интересно, как рассчитывалась яркость каждого символа?
Inspector-Due
Это делается, когда происходит конвертация из RGB в grayscale.
Конкретно в той библиотеке это сделано таким образом. На SO есть объяснение
Pab10
Не пиксела, ASCII-символа. Строка ASCII_CHARS составлена таким образом, что первые символы самые темные, последние — самые светлые. Вот интересно, как это было подобрано / рассчитано. За ссылки с разъяснениями спасибо.
Inspector-Due
Я попытался измерить "яркость" каждого символа, но у меня получилась другая последовательность
@MBW0Q8#O$&UqpbdmXa%kZhwoI[]unC}1{YJftzxjLvcl?|<>i)(/+!*r;~^":,-_'`
Как я измерял: брал каждый символ, рисовал его, далее переводил в grayscale, далее суммировал значение всех пикселей. Чем больше — тем ярче и наоборот. Я думаю, проблема в том, что я как-то неправильно рисую. Или, может быть, что все эти символы подбирались эмпирическим путём.
Вот результаты эксперимента: https://imgur.com/a/Ue8HEuB
Первый скриншот — "палитра" этой статьи, а второй — моя "палитра"