Якось зрозумів, що у повсякденній роботі не використовую 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