Якось зрозумів, що у повсякденній роботі не використовую canvas. Хоча завдяки його API можна створювати фантастичні проєкти
Наприклад, ось декілька сайтів, які зберіг собі для натхнення:
- Портфоліо з автомобілем 🏎️
- Генеративна графіка
- Багетний спринт
- Автомобільна гра з нескінченною дорогою
- Канвас для малювання
Тому щоб трохи зануритися в canvas API вирішив також створити невеличкий проєкт, але зі значно простішим функціоналом
Яка ідея проєкту?
Ідея проєкту — навчитися конвертувати картинки в ASCII символи
Приклад того, що планую отримати для картинок. Зліва — оригінальна картинка, справа — конвертована картинка в ascii символи

Реалізація
Алгоритм
Перед написанням коду формалізую алгоритм, за яким буде відбуватися перетворення картинки в символи:
- Діставання інформацію про колір кожного пікселя картинки
- Перетворення кольорового пікселя у сірий відтінок
- Заміна сірого відтінку на відповідний ascii символ
Основна ідея алгоритму — знайти спосіб представити якийсь колір з картинки у вигляді ASCII символу. У даному випадку буде відбуватися заміна насиченості кольору на символ — чим темніший колір, тим щільніший буде символ. Умовно: темно-голубий колір буде — Ñ, а світло-жовтий — +. Оскільки кольорів багато, то для їх нормалізації використовується перетворення у сірий відтінок. Наприклад, для rgb-системи доступно 256 відтінків сірого, якщо враховувати крайні точки у вигляді чорного та білого кольорів
Картинка для візуальної ілюстрації

Діставання інформації про колір картинки
В canvas api є метод getImageData, який повертає обʼєкт ImageData. Обʼєкт ImageData містити інформацію про колір кожного пікселя певної області canvas. Важливо розуміти, що інформація про колір пікселів картинки зберігається в одномірному масиві, де кожні 4 поспіль елементи масиву містять інформацію про колір одного пікселя в rgba.
Але як вставити картинку в canvas? Для цього є метод drawImage, який дозволяє відмалювати картинку в canvas
Текст вище у вигляді html та js коду:
<figure>
<img
src="https://images.unsplash.com/photo-1659992271797-c34f19b6f076?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2076&q=80"
alt=""
width="680"
class="image-js"
crossorigin
/>
<figcaption>Original Image</figcaption>
</figure>
<figure>
<canvas class="canvas-js"></canvas>
<figcaption>Canvas Image</figcaption>
</figure>
const img = document.querySelector('.image-js');
img.onload = function () {
const { width: sceneWidth, height: sceneHeight } = img;
const canvas = document.querySelector('.canvas-js');
const ctx = canvas.getContext('2d');
canvas.width = sceneWidth;
canvas.height = sceneHeight;
ctx.drawImage(img, 0, 0, sceneWidth, sceneHeight);
const imageData = ctx.getImageData(0, 0, sceneWidth, sceneHeight);
const data = imageData.data;
console.log(data); // масив з 1226720 елементів [109, 140, 151, 255, ...]
};
У даному шматочку коду спочатку очікується завантаження картинки. Після її завантаження — відбувається відмалювання картинки у canvas завдяки drawImage. Відмалювавши картинку, є можливість дістати інформацію про кольори пікселів через метод getImageData. У цьому прикладі метод getImageData повертає масив з 1226720 елементів. Перші чотири елементи масиву — [109, 140, 151, 255] відповідають за колір rgba(109, 140, 151, 255) першого пікселя картинки.
На даному етапі відбувається копіювання картинки з img в canvas
See the Pen rNrXQpy by VoloshchenkoAl (@VoloshchenkoAl) on CodePen.
Конвертація кольорів у сірі відтінки
Для мене було відкриттям, але існує як мінімум 13 різних методів перетворити певний колір у сірий відтінок. У даному прикладі буде використовуватися формула: $$g = {max(R, G, B) + min(R, G, B)\over 2}$$
Скомбінуємо цю формулу з кодом попереднього етапу
const imageData = ctx.getImageData(0, 0, sceneWidth, sceneHeight);
const data = imageData.data;
for (let x = 0; x < sceneWidth; x++) {
for (let y = 0; y < sceneHeight; y++) {
const pixelIndex = (x + y * sceneWidth) * 4; // конвертуємо двомірний масив у одновимірний
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const gray = rgbToGray(r, g, b);
data[pixelIndex] = gray;
data[pixelIndex + 1] = gray;
data[pixelIndex + 2] = gray;
}
}
ctx.putImageData(imageData, 0, 0);
function rgbToGray(r, g, b) {
return Math.round((Math.max(r, g, b) + Math.min(r, g, b)) / 2);
}
Код вище можна розділити на 3 складові:
-
Дістаємо інформацію про колір кожного пікселя з
canvasвикористовуючи вже відому функціюgetImageData. Інформація про кольори пікселів знаходяться в одновимірному масивіdata, тому щоб дістатися до інформації про колір пікселя, потрібно конвертувати двовимірне положення пікселя зcanvasв одновимірний відповідникdata. Власне це і відбувається у визначенні значення для змінноїpixelIndex. ЗначенняpixelIndexз кожною ітерацією множимо на 4, оскільки кольори вdataзберігаються вrgbaформаті, тобто 4 поспіль елементи відповідають за кожний з каналівrgba -
Конвертуємо отримане
rgbaзначення кольору у сірий відтінок -
Завдяки функції
putImageDataвідмальовуємо оновленуimageDataвcanvas
В результаті цих маніпуляцій отримуємо картинку конвертовану у відтінки сірого
See the Pen xxaKwbg by VoloshchenkoAl (@VoloshchenkoAl) on CodePen.
Конвертація відтінків сірого у ASCII символ
Найцікавіша частина: як конвертувати відтінки сірого у ASCII символи? На початку статті вже було проговорено правило: чим темніший піксель картинки — тим щільніший буде символ. Але що означає "щільний символ"? Умовно кажучи — чим щільніший символ використовуємо, тим темнішим він буде видаватися. Оскільки ASCII символів багато і вони доволі різноманітні, то вибравши з них потрібні елементи та впорядкувавши їх по щільності можна отримати градієнт, який подібний до градієнта відтінків сірого. У цьому випадку буде використовуватися послідовність Ñ@#W$9876543210?!abc;:+=-,._ , яка взята з сайту play.ertdfgcvb.xyz

Використовуючи функцію rgbToGray, яка описана вище, можливо отримати 256 відтінків сірого. Ці відтінки можна зіставити з послідовністю Ñ@#W$9876543210?!abc;:+=-,._ .
const imageData = ctx.getImageData(0, 0, sceneWidth, sceneHeight);
const data = imageData.data;
ctx.font = '1px monospace';
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, sceneWidth, sceneHeight);
ctx.fillStyle = 'black';
for (let x = 0; x < sceneWidth; x++) {
for (let y = 0; y < sceneHeight; y++) {
const pixelIndex = (x + y * sceneWidth) * 4;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const gray = rgbToGray(r, g, b);
const symbol = grayToSymbol(gray);
ctx.fillText(symbol, x, y);
}
}
function grayToSymbol(gray) {
const density = 'Ñ@#W$9876543210?!abc;:+=-,._ ';
const index = Math.round(((density.length - 1) / 255) * gray);
return density.charAt(index);
}
У даному коді відбулось декілька змін:
- Прибрано відмальовування картинки на
canvas, оскільки нам потрібно відображатиasciiсимволи - Додано функцію
grayToSymbol, яка конвертує відтінок сірого уasciiсимвол - Перед ітерацією описано декілька маніпуляцій з
canvas, які необхідні для коректного відображення символів - У самій ітерації відбувається заміна кожного пікселя картинки на відповідний символ
See the Pen ExeYVMb by VoloshchenkoAl (@VoloshchenkoAl) on CodePen.
Але результат виглядає не дуже 😭
Калібрування алгоритму
Результат з попереднього кроку не задовільняє моїх очікувань. Символи відображаються маленькими, тому їх зовсім не видно. Щоб виправити це, спробую збільшити розмір символів, а також буду відображати на канвасі кожний шостий піксель
const PIXEL_SHIFT = 6;
ctx.font = `${PIXEL_SHIFT}px monospace`;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, sceneWidth, sceneHeight);
ctx.fillStyle = 'black';
for (let x = 0; x < sceneWidth / PIXEL_SHIFT; x++) {
for (let y = 0; y < sceneHeight / PIXEL_SHIFT; y++) {
const pixelIndex = (x + y * sceneWidth) * 4 * PIXEL_SHIFT;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const gray = rgbToGray(r, g, b);
const symbol = grayToSymbol(gray);
ctx.fillText(symbol, x * PIXEL_SHIFT, y * PIXEL_SHIFT);
}
}
У даному коді додано змінну PIXEL_SHIFT, яка відповідає за кількість пікселів, які будуть пропускатися при кожній ітерації
Це вже краще
See the Pen rNZBeNo by VoloshchenkoAl (@VoloshchenkoAl) on CodePen.
Тепер спробуємо інвертувати кольори та символи
const PIXEL_SHIFT = 6;
ctx.font = `${PIXEL_SHIFT}px monospace`;
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, sceneWidth, sceneHeight);
ctx.fillStyle = 'white';
// ...
function grayToSymbol(gray) {
const density = 'Ñ@#W$9876543210?!abc;:+=-,._ '.split('').reverse().join('');
const index = Math.round(((density.length - 1) / 255) * gray);
return density.charAt(index);
}
З цими змінами фон canvas відмальовується у чорному кольори, а символи — білим. При цьому зміна торкнулась функції grayToSymbol — щільніший символ тепер відповідає світлішому відтінку сірого
Результат фінальної ітерації
See the Pen MWqgyKP by VoloshchenkoAl (@VoloshchenkoAl) on CodePen.
Матеріали
Для написання статті використовував документацію MDN з описом маніпуляції пікселів на canvas та відео з YouTube ASCII Text Images з p5.js