Я долго размышлял, с чего всё началось, и, кажется, теперь ясно вижу ту самую точку, откуда пошёл настоящий путь. Ещё в институте, когда мы изучали C/C++, мне внезапно пришла в голову мысль попробовать превращать изображения в ASCII-арт, просто ради эксперимента. Несколько бессонных ночей за кодом, и у меня получилось.

Мне удалось создать небольшую утилиту, которая получала на вход изображение и превращала его в ASCII-арт с помощью нехитрых преобразований. Разумеется, до идеала было далеко, но работало всё вполне достойно. Поигравшись немного, я вскоре о ней забыл... утилита ушла на дальний план студенческой жизни и со временем покрылась пылью.

Долго не значит заброшено

Спустя двадцать пять лет я неожиданно вернулся к этому проекту — мне понадобилось сделать красивое приветствие при входе по SSH на роутер. Хотелось чего-то необычного, и тут я вспомнил свой старый ASCII-арт. Но, поиграв с ним немного, понял: теперь мне хочется чего-то посложнее и поинтереснее.

Пример красивого шрифта
Пример красивого шрифта

Я нашёл графический редактор, позволяющий рисовать прямо в текстовом режиме. Параллельно открыл для себя коллекции текстового арта, наборы японских смайлов и другие необычные ресурсы. В процессе наткнулся и на декоративные шрифты.

Пример красивых шрифтов с просторов интернета
Пример красивых шрифтов с просторов интернета

Честно говоря, именно тогда у меня и возникло желание копнуть глубже. Хотелось добиться максимально реалистичного отображения в терминале, не просто текста, а любой картинки, причём на совершенно ином уровне качества. Понимая внутренние механизмы, я решил, что мой старый студенческий проект стоит продолжить, но уже с совсем другим подходом и новыми задачами.

Для начала мне пришла идея использовать пробел как «выключенный» пиксель, а символ █ как «включённый», и пытаться строить изображение, учитывая прямоугольную форму терминального пикселя. Сказать, что ничего не вышло, было бы неправдой, но и результат получился довольно посредственный... явно не то, к чему я стремился.

Снимок экрана 2025-12-02 в 23.04.40.png
Самый первый черно белый вариант с прямоугольным размером пикселя

Раз уж какой-то результат всё же был, я решил развить идею дальше. Нашёл символ для нижнего пикселя и для верхнего, таким образом один глиф позволял кодировать сразу два пикселя. Картинка действительно стала заметно лучше! По сути, у меня появилась сетка пикселей, собранная всего из четырёх символов, и она уже позволяла создавать гораздо более выразительные изображения.

Половина пикселя: пробел, прямоугольный пиксель █, нижний ▄ и верхний ▀
Половина пикселя: пробел, прямоугольный пиксель █, нижний ▄ и верхний ▀

После ещё пары размышлений я решил пойти дальше и попробовать использовать не только половинки, но и четверти пикселя. Для этого подошёл весь набор символов псевдографики:  , ▄, █, ▀, ▟, ▙, ▛, ▜, ▐, ▌, ▘, ▝, ▗, ▖, ▚, ▞. В итоге удалось добиться весьма достойного отображения изображения в терминале. Более того, получившуюся «картинку» можно было выделить, скопировать и отправить сообщением. Единственной заметной проблемой оставалось требование моноширинного шрифта, без него вся структура расползалась.

Четыре пикселя на один символ
Четыре пикселя на один символ

Добавим качества

На этом я не остановился и решил добавить оттенки серого и тут сразу возникли сложности. Раньше я оперировал «включёнными» и «выключенными» глифами, но для передачи цвета такая логика уже не подходила. Оказалось, что для цветного отображения мне нужна лишь половина исходного набора символов. Например, пробел вовсе не требовал своего антипода █, сам по себе он мог быть любого цвета, и именно цвет, а не форма глифа, задавал нужный тон.

Градации серого, только пробел в качестве прямоугольного пикселя
Градации серого, только пробел в качестве прямоугольного пикселя

По той же логике я отбросил половину символов и оставил лишь два основных глифа, пробел и верхнюю половину пикселя ▀. Кодирование стало двухцветным: один цвет задавал фон (background), другой сам символ (foreground). Такой подход позволил значительно гибче управлять оттенками и приблизиться к полноценному полутоновому изображению в терминале.

Пробел и половинка пикселя
Пробел и половинка пикселя

Дальше всё усложнилось. В предыдущем подходе у нас был один глиф и два цвета, то есть фактически два пикселя. Но при делении на четверти каждый символ начинает представлять уже четыре пикселя: ▘, ▝, ▖, ▗, ▐, ▞. Возникает несоответствие: пикселей, четыре, а цветов, которые один глиф способен отобразить, только два (цвет фона и цвет символа).

В упрощённом виде это можно описать так: внутри одного текстового блока у нас есть четыре независимых «пикселя», но всего два доступных цвета, и вся задача сводится к тому, чтобы подобрать оптимальное распределение этих двух цветов так, чтобы визуально имитировать четыре.

Предположим, что цвет «2» самый тёмный, а оттенки 0, 1 и 3 заметно светлее. При этом каждый глиф, доступный для отрисовки, позволяет использовать лишь два цвета, цвет фона и цвет символа, хотя самих вариантов глифов (с учётом инверсий) около шестнадцати. В итоге мы сводим исходные четыре пикселя к двум усреднённым значениям: тёмной группе (цвет 2) и светлой группе (0, 1, 3).

После такого упрощения можно выбрать наиболее подходящий паттерн, например, символ ▖ который лучше всего передаст нужное распределение света и тени. Применяя такую нехиртую технику удалось получить ещё более детализированное отображение:

Добавим цвета

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

А можно ещё улучшить? ДА!

Получалось уже весьма неплохо даже с сеткой 2×2. Но если посмотреть на доступные шрифты и имеющиеся символы, можно обнаружить куда более богатый набор глифов, позволяющий работать с более глубокой сеткой — вплоть до 4×8! С помощью вот этих символов: ▁▂▃▄▅▆▇▊▌▎▖▗▘▚▝━┃┏┓┗┛┣┫┳┻╋╸╹╺╻╏──││╴╵╶╷⎢⎥⎺⎻⎼⎽▪

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

Было
Было
Стало
Стало

Неплохо? Особенное если учесть что это просто раскрашеный текст в терминале! Если выделить текст можно увидеть все символы которыми было нарисовано изображение.

При выделении в терминале, мы видим глифы из которых состоит изображение
При выделении в терминале, мы видим глифы из которых состоит изображение

Ты изобрел велосипед и что дальше то, а главное нафига?

После первых удачных результатов я на время забросил эту идею, занялся другими проектами и делами. Так продолжалось до недавнего момента, пока меня внезапно не осенило: почему бы не применить всё это не только ради эксперимента, но и для чего-то действительно полезного?

И у меня сразу возникли две идеи. Первая, попробовать применить этот подход не только в текстовом выводе, но и приблизить его к тому, как это делалось на компьютере ZX Spectrum: использовать глифы как полноценные графические блоки. Тогда можно было бы рисовать не в терминале, а настоящей «плиточной» графикой, где каждый паттерн служит мини-изображением, а вся картинка собирается из таких блоков.

И нет, я не сошёл с ума, подобный подход действительно может работать. Паттерны с двумя цветами на блок вполне способны существовать не только в виде текста, но и как полноценная графическая техника. Такой принцип уже применялся, и яркий пример это графика компьютера ZX Spectrum.

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

На спектруме вместе с подходом отрисовки паттернами так же использовалась скудная палитра из 16 цветов.

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

Чтобы быстро проверить жизнеспособность идеи, я наваял небольшой тестовый проект и начал эксперименты. Сначала взял блок 4×4 в качестве базового паттерна, закодировал изображение в собственный формат, а затем декодировал его обратно в PNG для наглядности. На диске такое сжатое представление занимало меньше места, чем QOI, а иногда и меньше, чем JPEG при сопоставимом качестве. При разжатии же изображение сохраняло узнаваемые детали, что позволило объективно оценить результат.

Первая попытка при сильном увеличении видны паттерны кодирования .babe
Первая попытка при сильном увеличении видны паттерны кодирования .babe
JPEG для сравнения
JPEG для сравнения

Рождение нового формата .babe

Я прогнал серию тестов с разными размерами блоков от 1×1 до 8×8, включая варианты с прямоугольными паттернами. Результат был вполне ожидаем: чем больше блок, тем ниже качество, но тем меньше весит итоговый файл. Однако, чтобы этот формат можно было считать серьёзным и практически полезным, нужно было избавиться от характерных артефактов лестниц на градиентах и резкой блочности на контрастных переходах. Эти дефекты бросались в глаза в первую очередь и сильно портили общее впечатление.

Первым делом я добавил переменный размер блока и ввёл понятие «сплошного блока» такого, где используется всего один цвет, что позволяло заметно экономить место при хранении. Алгоритм работал просто: он брал участок изображения, проверял ошибку при попытке закодировать его выбранным паттерном и, если она оказывалась слишком высокой, рекурсивно делил блок на две равные части, после чего пытался закодировать их отдельно.

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

Однако я добавил одну небольшую хитрость: начал кодировать каждый цвет отдельно. Это неожиданно дало куда более чистый результат, чем любые попытки усложнить структуру блоков.

Таким образом мне удалось полностью избавиться от «лесенки». Теперь это стало похоже на полноценный формат изображений. Размер файла, конечно, немного вырос, но рост был предсказуемым, да и серьёзных оптимизаций я ещё не проводил. Немного доработав алгоритм, я наконец получил тот результат, к которому стремился. Алгоритм работает быстрее чем QOI и значительно быстрее чем JPEG, сжимает лучше QOI, иногда на уровне JPEG.

Готовый проект я оформил на GitHub под лицензией MIT: https://github.com/svanichkin/babe там же доступны релизы для некоторых платформ. Название получилось как аббривиатура из Bi-Level Adaptive Block Encoding, сокращенно babe.

Хаха... очередной формат файла?

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

Сначала я попробовал JPEG, но для 30 fps он оказался слишком тяжёлым. Затем обратил внимание на формат QOI, он сжимает неплохо и использует простую математику, почти не нагружая CPU. Но и он не подошёл: итоговый размер всё равно был великоват, а скорость сжатия не впечатлила.

Я даже попробовал реализовать кодек H.261, частично получилось, но вскоре понял, что это не тот путь, и забросил идею. Зато мой собственный формат оказался золотой серединой: он сжимал быстрее, чем QOI и тем более JPEG, а итоговый размер получался меньше, чем у QOI, хотя и больше, чем у JPEG при том же качестве.

Формат словно изначально получился «видеодружелюбным»: блочное кодирование и фиксированные палитры для ключевых кадров отлично ложатся на модель видеокодека. К самому видеокодеку я вернусь позже, когда появится больше времени. Пока же могу показать результаты тестов: я брал оригинальный JPEG, сжимал его в BABE, затем декодировал обратно в PNG.

70% качество, размер 333 Кб
70% качество, размер 333 Кб
30% качество, размер 160 Кб
30% качество, размер 160 Кб
0% качество, размер 109 Кб
0% качество, размер 109 Кб
70%, 400 Кб
70%, 400 Кб
0%, 129 Кб
0%, 129 Кб

Самое приятное, алгоритм работает очень быстро и почти не нагружает CPU, при этом выдавая отличную картинку. Если увеличить PNG, можно заметить артефакты от снижения качества, но при обычном просмотре они почти не бросаются в глаза.

Так что там за вторая идея?

Пора перейти ко второй основной идее, собственно ради неё я и решил написать эту статью. Мне хотелось проверить несколько задумок, и главная заключалась в том, чтобы дать возможность создавать видеозвонок прямо из строки терминала. Идея звучала почти нереализуемой, но немного «вайбкода» и прототип удалось собрать буквально на коленке.

В качестве аудиокодека я сначала использовал Opus, но позже перешёл на G.722. Несмотря на то что G.722 требует более широкого канала, его простота и скорость кодирования подошли лучше. К тому же никаких внешних зависимостей не понадобилось всё работает прямо внутри бинарника.

Работало это так: на одном компьютере запускалась утилита, она выдавала адрес, мы передавали его собеседнику, и тот выполнял дозвон. Всё выглядело неплохо, но хотелось большего. И тут я вспомнил свои эксперименты с отображением картинок в терминале и подумал: а почему бы и нет?

Say, это звонок прямо в окне терминала, никаких GUI, p2p
Say, это звонок прямо в окне терминала, никаких GUI, p2p

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

Для дозвона я не стал использовать готовые сигнальные протоколы вроде WebRTC, RTMP или SIP. Всё свёл к простому рукопожатию по TCP и передаче пакетов по UDP. На каждом этапе от микрофона до вывода на экран используются буферы, а вся логика заточена под скорость и минимальную загрузку CPU.

По трафику получилось следующее:

С Opus трафик держался около 1 КБ/с, с G.722 вырос примерно до 5 КБ/с. Видео же зависит от размера вьюпорта и «движухи» в кадре, но в среднем выходит около 20 КБ/с при 25 FPS. Иными словами, для стабильной работы звонка достаточно очень узкого канала... вплоть до ~100 КБ/с.

Ссылка на проект: https://github.com/svanichkin/say, звёзд пока нет, он только запущен. Сейчас я продумываю, как лучше интегрировать его в распространённые менеджеры пакетов: ports, Homebrew, apt и другие, чтобы установка была максимально простой и доступной на разных платформах.

Это проект длиной в 30 лет... Работа начиналась как простой эксперимент с текстовой графикой, а в итоге привела к появлению собственного формата изображений, аудио- и видеопрототипов и целого стека необычных технических решений. Всё это результат чистого интереса и желания проверить, «а что если».

Проекты цельные готовые к употреблению, и уже сейчас видно, что направление оказалось живым и перспективным. Буду рад, если кому-то это вдохновит попробовать свои идеи, даже самые странные, ведь именно они чаще всего приводят к неожиданным открытиям.

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