Если открыть произвольный JPEG-файл в блокноте, то можно увидеть лишь хаотичный набор символов. Отсюда вопрос: возможно ли закодировать изображение так, чтобы его было можно просмотреть не только обычным способом, но и в обычном блокноте, в виде ASCII-графики. Ответ положительный, если использовать максимальное сжатие:
Grayscale (только оттенки серого).
Обнулить все AC-коэффициенты, то есть весь блок 8x8 пикселей сделать одноцветным.
Задать максимальный шаг квантования DC-коэффициента - 255. Это ограничивает цвет всего 9 оттенками серого: 0, 32, 64, 96, 128, 160, 192, 224, 255.
Вот так:
Да, не очень впечатляет. Но, тем не менее, это все еще JPEG, каждый блок 8x8 которого закодирован одним ASCII-символом:
Практическое применение отсутствует. Просто забавно.
Минимум теории
Для каждого блока 8x8 пикселей выполняется дискретное косинусное преобразование, результатом которого является один DC- и 63 AC-коэффициента. DC-коэффициент может принимать значения от -1020 до 1020 (255*4). Для grayscale-изображения минимальному значению соответствует черный цвет, максимальному - белый. В случае, если весь блок полностью одноцветен, все 63 AC-коэффициента равны 0.
Затем коэффициенты квантуются, то есть делятся на некоторое число и округляются. Наибольший возможный шаг квантования - 255. Это максимальный уровень сжатия. Тогда DC-коэффициенты принимают значения от -4 до 4. При записи в файл используется дельта-кодирование: сохраняются разности квантованных коэффициентов текущего и предыдущего блоков. То есть, при шаге квантования 255, дельта-значения могут быть от -8 до 8. Например, если изображение начинается с двух белых блоков после которых идет черный (4, 4, -4), то последовательность будет записана как: 4, 0, -8.
Коэффициенты записываются с помощью кодов (деревьев) Хаффмана. Эти коды хранятся в самом JPEG-файле. Для серого изображения нужно 2 дерева - одно для DC, другое для AC. Для решаемой задачи деревья были построены специальным образом.
Декодирование
Битовый поток начинается с байта 0xA, то есть 00001010
в двоичном представлении. Это символ переноса строки, который необходим в самом начале, иначе ASCII-графика окажется на одной строке вместе с заголовком JPEG. Декодер при чтении этих битов проходит по дереву Хаффмана, каждый раз выбирая ветвь в зависимости от значения бита.
Чтение 3 битов 000
приводит к листу со значением 1. Это означает, что нужно прочитать 1 следующий бит 0
, чтобы получить DC-коэффициент. Если первая цифра значения в двоичном представлении — 1, то оставляем как есть: DC = <значение>
. Иначе преобразуем: DC = <значение>-2^<длина значения>+1
. В нашем случае DC = 0 - 2^1 + 1 = -1. Обратите внимание, что если последовательность битов начинается на 011000
, то значение листа равно нулю. В этом случае DC = 0.
Далее опять осуществляется проход по дереву Хаффмана, но уже для AC-коэффициентов. Биты 1010
приводят к листу со значением 0. Как можно видеть, других значений в этом дереве нет. Ноль означает, что нужно прекратить чтение AC-коэффициентов и обнулить оставшиеся. Поэтому в нашем случае все AC-коэффициенты равны нулю и в этом блоке и во всех других.
Весь остальной поток читается аналогично, но, как уже было сказано: начиная со второго блока читаются не сами DC-коэффициенты, а разности (дельты) между коэффициентом текущего и предыдущего блоков. Используемое дерево Хаффмана для DC-коэффициентов позволяет закодировать максимум 3-битные дельты — от -7 до 7. Значит крайние значения DC должны различаться не более чем 7. Например, от -3 до 4 (то есть совсем черный цвет уже не получится). Добавить 4-битный код у меня нормально не получилось.
Все встречаемые символы приведены в следующей таблице.
HEX ASCII DC AC Дельта
60 ` [011000 00] 0
4C L [0100 1 100] +1
44 D [0100 0 100] -1
58 X [0101 10 00] +2
54 T [0101 01 00] -2
5C \ [0101 11 00] +3
50 P [0101 00 00] -3
30 0 [001 100 00] +4
2D - [001 011 01] -4
34 4 [001 101 00] +5
28 ( [001 010 00] -5
38 8 [001 110 00] +6
24 $ [001 001 00] -6
3C < [001 111 00] +7
21 ! [001 000 01] -7
10 LF [000 0 1010] 1
Было бы хорошо, например, белый блок кодировать пробелом. Но из-за дельта-кодирования мы можем лишь сопоставить символам разность цветов соседних блоков. Это тоже неплохо, так как разность цветов позволяет четко выделить границы объектов на изображении. Тогда почему бы не использовать пробел для одноцветных блоков? У него неудобное двоичное представление 00100000
из-за которого у меня не получилось использовать 3-битные дельты. Пришлось искать какой-нибудь альтернативный символ. Самый малозаметный - обратный апостроф ( ` ).
Символ переноса строки не только "рисует" мусорный столбец на изображении, но уменьшает дельту на 1. Поэтому после него добавляется символ L, восстанавливающий дельту.
Все это реализовано с помощью простейшей программы на Питоне (Github), позволяющей генерировать jpg-файл в описанном формате по любому исходному изображению.
Пример. Можно скачать и проверить. В новой версии Хабра нужно сначала щелкнуть по картинке, так как отображается пережатая версия. Или скачать картинку по прямой ссылке.
Комментарии (50)
tmin10
11.08.2021 17:11+19Я как-то написал программу визуализации бинарников: брало байты и рисовало пикселы нужного цвета. Как же я удивился, когда нарисовал explorer.exe из XP, а там показался скриншот рабочего стола. Окалазось, что в бинарник был встроен BMP с разными видами меню пуск из настроек и оно успешно отрисовалось моей программой.
dcoder_mm
13.08.2021 11:57Я так контроллер ЖК дисплея на STM32 осваивал, и для первой попытки кинул ему вместо фреймбуфера начальный адрес флеш памяти. После моей прошивки оказалась незатертая часть демо-прошивки, которая шла вместе с платой, и в на экране отлично были видны картинки, хранящиеся в памяти
dcoder_mm
11.08.2021 17:24+14По заголовку подумал, что сейчас про rarjpeg расскажут, а тут такое веселье. Спасибо
zhka
11.08.2021 17:27+16Напоминает известную иллюстрацию ограниченности применения ECB-режима блочных шифров:
sim31r
11.08.2021 23:49Строго говоря если jpeg слева сжать, то ничего видно не будет, артефакты сжатия добавят шум которые неузнаваемо изменит результат. Артефакты сжатия видны при увеличении картинки невооруженным глазом, если кликнуть по картинке. А если оригинал 10 000 * 10 000 пикселей и оригинал без потерь информации (bmp, png формат), тогда да, при шифровании блочным шифром будет видна структура оригинала.
У кого программа шифрования под рукой можно проверить именно на картинке слева, именно что прикреплена, а не оригинал. Думаю не останется ни одного узнаваемого пикселя.
LynXzp
12.08.2021 00:23А с jpg который в статье так не пойдет. (Хотя размер блока должен быть равен 1 байту чтобы осталось видно)
zhka
12.08.2021 00:39+10Но я ради интереса выполнил предложенный Вами эксперимент.
Я сделал скриншот левого изображения из моего комментария со всеми артефактами пережатия вот прямо "как есть", конвертировал его в bmp, получил файл 478x536 пикселей и размером примерно в 1 мегабайт.
Далее я зашифровал его таким образом:
openssl enc -des-ecb -in tux.bmp -nosalt -out out.bmp
(DES выбрал из-за размера блока. Все таки изображение достаточно небольшое)
Чтоб посмотреть результат в обычной программе просмотра изображений, я заменил первые 90 байт получившегося шифрованного файла на оригинальные (по википедии это максимальный размер заголовка BMP)
И вот что получилось:Возможно, где-то ошибся в процессе, но мне кажется, внешний контур рисунка вполне угадывается... )
sim31r
12.08.2021 13:05Да всё правильно, jpeg оказался не такой зашумленный и целые области фона и живота пингвина остались без единого сбойного пикселя с одинаковым цветом. Мне казалось что артефакты сжатия тянутся от контуров до самого края, а они быстро снизились до нуля.
BigBeaver
12.08.2021 16:33+1Мне казалось что артефакты сжатия тянутся от контуров до самого кра
С какой бы радости? Он же окнами жмёт.
skvernoslov
12.08.2021 10:52А разве применение режима ECB шифрования приводит к изменению исходных данных?
tyomitch
12.08.2021 11:07+2Эээ, любое шифрование приводит к изменению исходных данных.
Если шифротекст совпадает с исходным текстом, то в чём смысл?
Moskus
11.08.2021 19:21Что примечательно - я не могу сходу вспомнить утилиту командной строки, которая позволяла бы манипулировать к-тами AC и DC при сжатии.
EviGL
11.08.2021 22:31+4Забавно, что перекодировщик хабра из 31кб оригинальной картинки сгенерировал 144кб "уменьшенную версию" :)
Я сначала скачал её и, естественно, ничего не сработало. Если что, на ПК надо нажать на картинку для зума и только потом её скачать.
Fil Автор
11.08.2021 22:42А, у меня была старая версия Хабра, в ней все норм. Спасибо, добавил примечание!
Self_Perfection
11.08.2021 22:56+4Да это типичнейший программистский ляп. ВК, Facebook, telegram - все подряд если отправлять в сообщении оптимизированный png как изображение прекрасно понимают, что это картинка, конвертируют в jpg, добавляя заметных артефактов и увеличивая размер. Очень бесит.
Balling
13.08.2021 09:37А вот twitter поддерживает исходник. В крайнем случае через :orig в URI. 444 тоже поддерживается.
votetoB
11.08.2021 22:41+2Практическое применение отсутствует. Просто забавно.
Моя улыбка (как и всех остальных хабровчан) очень даже практична и полезна. Не стоит её недооценивать. Всех
с пятницейсо средой!!
bolk
12.08.2021 00:24+3О, мне та же мысль пришла пару лет назад: https://bolknote.ru/all/kartinka-v-terminale/
Daddy_Cool
12.08.2021 01:32Забавно!
Много-много лет назад, для однокурсницы написал графический редактор который из bmp делал txt. Дальше она на файл натравливала нейросеть и что-то там делала.
AirLight
12.08.2021 02:57Почему практического применения нет? Очень даже есть. Бывают системы без графического интерфейса, типичный ssh-терминал, например. И они всегда будут. Если стандарт jpeg доработать, то можно вначале файла пихать в нем символьное представление. Это не сильно утяжелит файл.
Error1024
12.08.2021 06:09+2Зачем? Неужели усложнение стандартных форматов файлов картинок, которые итак чертовски сложны, стоит 0.1% гиков, которым «прикольного наблюдать картинку в терминале»
skvernoslov
12.08.2021 10:33-1Есть ли готовая реализация на PHP?
nikolaevevge
13.08.2021 11:15Вы в серьёз собираетесь применить это на практике? Можете рассказать для чего например?
Spectrum-Hyena
12.08.2021 12:41-1Шрифты же у нас давно не квадратные, а прямоугольные, что видно на вытянутых ASCII. Точно не уверен, как сейчас, но у vga был режим знакоместа 8*16, т.е. 1 к 2, почему бы и тут не использовать схожие параметры?
Fil Автор
12.08.2021 12:48+2Вы ещё один, который подумал, что я сделал рендерер в ascii. Одни и те же символы предназначены и для визуализации и для декодера
ArtRoman
12.08.2021 19:58+1Стоило показать весь jpg-файл в блокноте, не всем очевидно что кроме самого текстового рисунка там больше ничего нет, это не rar-jpeg.
Меня ещё отсутствие стандартного заголовка JFIF, который есть почти в каждой картинке. Узнал, что истинный заголовок jpg — это байты 0xFF 0xD8 0xFF.
Fil Автор
12.08.2021 23:11+1Метаданные все равно остаются. Это тоже может быть непонятно.
JFIF в начале - это комментарий, там может быть что угодно. У вас 3-й байт 0xFF относится уже к следующему маркеру. Парсер пропускает все от данных (структрура которых задается маркером) до следующего маркера. У начального маркера данных нет, поэтому файл может начинаться например как 0xFF, 0xD8, "Hello world", 0xFF, 0xDB.
KvanTTT
12.08.2021 13:58+1Интересно, а можно ли подобным образом встроить исходный код, который тоже делал что-нибудь полезное по типу генерации этой же картинки?
ArtRoman
12.08.2021 20:04+1Это уже следующий уровень вложенности, учитывая что ещё надо сделать код, который бы "генерировал" картинку, а не просто, скажем, распаковывал или декодировал. К тому же, как сделать граф, который бы выдавал разные символы на одни и те же байты изображения? Возможно, тут уже придётся возвращаться к цвету, меняя оттенки исходного изображения, чтобы был необходимый запас цветов. Но тогда наоборот, один и тот же символ не может выдавать разные цвета.
NumLock
(Linux) cacaview - ASCII image browser.
DrPass
Но вот тот, что получился у автора, можно прям в терминал вывалить без дополнительных утилит :)
ne555
для сравнения в cacaview в cli
А автору нужно перехватывать исключение (когда не указываются вх/вых данные:
Fil Автор
Насчет исключения да, спасибо, надо поправить. Но судя по тому, что вы сохранили как txt, замечу, что это не рендерер img->ascii. Более наглядно получается, если в исходном изображении нет мелких деталей.
MentalBlood
Кстати, рекомендую argparse. Уже давно в стандартной библиотеке. Использовать легко и просто, при этом есть глубокие фичи вроде вложенных парсеров
Fil Автор
Я с питоном не очень знаком. Спасибо, попробую )
kaka888
Как сделать такой же терминал на телефоне, как у вас?
DCNick3
Можно попробовать Termux. Там что-то вроде своего дистрибутива Linux, который может работать в песочнице андроида без рута, много стандартных программ есть
Harpagon
Это приложение JuiceSSH.
zagayevskiy
Нужно не исключения перехватывать, а входные данные валидировать.
saboteur_kiev
Такое ощущение, что у вас не fixed-font, а это важно для asc2 art