Если открыть произвольный 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)


  1. NumLock
    11.08.2021 16:53
    +4

    (Linux) cacaview - ASCII image browser.


    1. DrPass
      11.08.2021 16:58
      +13

      Но вот тот, что получился у автора, можно прям в терминал вывалить без дополнительных утилит :)


      1. ne555
        11.08.2021 19:39
        +1

        получается не очень
        получается не очень
        для сравнения в cacaview в cli

        А автору нужно перехватывать исключение (когда не указываются вх/вых данные:

        File "/home/user/soft/jpgtxt/jpgtxt.py", line 81, in <module>
            jpgtxt(sys.argv[1], sys.argv[2])
        IndexError: list index out of range


        1. Fil Автор
          11.08.2021 19:46
          +1

          Насчет исключения да, спасибо, надо поправить. Но судя по тому, что вы сохранили как txt, замечу, что это не рендерер img->ascii. Более наглядно получается, если в исходном изображении нет мелких деталей.


          1. MentalBlood
            11.08.2021 19:50
            +1

            Кстати, рекомендую argparse. Уже давно в стандартной библиотеке. Использовать легко и просто, при этом есть глубокие фичи вроде вложенных парсеров


            1. Fil Автор
              11.08.2021 19:52

              Я с питоном не очень знаком. Спасибо, попробую )


        1. kaka888
          11.08.2021 19:53

          Как сделать такой же терминал на телефоне, как у вас?


          1. DCNick3
            12.08.2021 00:15

            Можно попробовать Termux. Там что-то вроде своего дистрибутива Linux, который может работать в песочнице андроида без рута, много стандартных программ есть


          1. Harpagon
            12.08.2021 06:34

            Это приложение JuiceSSH.


        1. zagayevskiy
          11.08.2021 20:43
          +1

          Нужно не исключения перехватывать, а входные данные валидировать.


        1. saboteur_kiev
          12.08.2021 11:40

          Такое ощущение, что у вас не fixed-font, а это важно для asc2 art


  1. tmin10
    11.08.2021 17:11
    +19

    Я как-то написал программу визуализации бинарников: брало байты и рисовало пикселы нужного цвета. Как же я удивился, когда нарисовал explorer.exe из XP, а там показался скриншот рабочего стола. Окалазось, что в бинарник был встроен BMP с разными видами меню пуск из настроек и оно успешно отрисовалось моей программой.


    1. dcoder_mm
      13.08.2021 11:57

      Я так контроллер ЖК дисплея на STM32 осваивал, и для первой попытки кинул ему вместо фреймбуфера начальный адрес флеш памяти. После моей прошивки оказалась незатертая часть демо-прошивки, которая шла вместе с платой, и в на экране отлично были видны картинки, хранящиеся в памяти


  1. dcoder_mm
    11.08.2021 17:24
    +14

    По заголовку подумал, что сейчас про rarjpeg расскажут, а тут такое веселье. Спасибо


  1. zhka
    11.08.2021 17:27
    +16

    Напоминает известную иллюстрацию ограниченности применения ECB-режима блочных шифров:


    1. sim31r
      11.08.2021 23:49

      Строго говоря если jpeg слева сжать, то ничего видно не будет, артефакты сжатия добавят шум которые неузнаваемо изменит результат. Артефакты сжатия видны при увеличении картинки невооруженным глазом, если кликнуть по картинке. А если оригинал 10 000 * 10 000 пикселей и оригинал без потерь информации (bmp, png формат), тогда да, при шифровании блочным шифром будет видна структура оригинала.

      У кого программа шифрования под рукой можно проверить именно на картинке слева, именно что прикреплена, а не оригинал. Думаю не останется ни одного узнаваемого пикселя.


      1. zhka
        12.08.2021 00:22
        +1

        Ну, о JPEG тут речи и нет. Это о BMP и подобных форматах.


        1. sim31r
          12.08.2021 13:07

          Растровый формат конечно, после операции разжатия. JPEG как исходник с частично потерянной информацией.


      1. LynXzp
        12.08.2021 00:23

        А с jpg который в статье так не пойдет. (Хотя размер блока должен быть равен 1 байту чтобы осталось видно)


      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)
        И вот что получилось:

        Возможно, где-то ошибся в процессе, но мне кажется, внешний контур рисунка вполне угадывается... )


        1. sim31r
          12.08.2021 13:05

          Да всё правильно, jpeg оказался не такой зашумленный и целые области фона и живота пингвина остались без единого сбойного пикселя с одинаковым цветом. Мне казалось что артефакты сжатия тянутся от контуров до самого края, а они быстро снизились до нуля.


          1. BigBeaver
            12.08.2021 16:33
            +1

            Мне казалось что артефакты сжатия тянутся от контуров до самого кра
            С какой бы радости? Он же окнами жмёт.


    1. skvernoslov
      12.08.2021 10:52

      А разве применение режима ECB шифрования приводит к изменению исходных данных?


      1. tyomitch
        12.08.2021 11:07
        +2

        Эээ, любое шифрование приводит к изменению исходных данных.
        Если шифротекст совпадает с исходным текстом, то в чём смысл?


  1. VolodjaT
    11.08.2021 18:56

    2girls1cat.jpg

    -фух, хорошо что не 2girls1cup.jpg :D


    1. aleksandy
      11.08.2021 19:45

      Так-то это было видео. :)


  1. Moskus
    11.08.2021 19:21

    Что примечательно - я не могу сходу вспомнить утилиту командной строки, которая позволяла бы манипулировать к-тами AC и DC при сжатии.


  1. mitasamodel
    11.08.2021 22:23

    А ещё этот jpeg хорошо сжимается архиваторами


  1. EviGL
    11.08.2021 22:31
    +4

    Забавно, что перекодировщик хабра из 31кб оригинальной картинки сгенерировал 144кб "уменьшенную версию" :)

    Я сначала скачал её и, естественно, ничего не сработало. Если что, на ПК надо нажать на картинку для зума и только потом её скачать.


    1. Fil Автор
      11.08.2021 22:42

      А, у меня была старая версия Хабра, в ней все норм. Спасибо, добавил примечание!


    1. Self_Perfection
      11.08.2021 22:56
      +4

      Да это типичнейший программистский ляп. ВК, Facebook, telegram - все подряд если отправлять в сообщении оптимизированный png как изображение прекрасно понимают, что это картинка, конвертируют в jpg, добавляя заметных артефактов и увеличивая размер. Очень бесит.


      1. dcoder_mm
        11.08.2021 23:11

        Спасибо, что не наоборот. JPEG -> PNG легко может раздуть картинку в 15 раз.


        1. Balling
          13.08.2021 09:38

          Да, из-за артефактов сжатия. Например, скриншоты лучше делать в png.


      1. Balling
        13.08.2021 09:37

        А вот twitter поддерживает исходник. В крайнем случае через :orig в URI. 444 тоже поддерживается.


  1. votetoB
    11.08.2021 22:41
    +2

    Практическое применение отсутствует. Просто забавно.

    Моя улыбка (как и всех остальных хабровчан) очень даже практична и полезна. Не стоит её недооценивать. Всех с пятницей со средой!!


  1. bolk
    12.08.2021 00:24
    +3

    О, мне та же мысль пришла пару лет назад: https://bolknote.ru/all/kartinka-v-terminale/


  1. Daddy_Cool
    12.08.2021 01:32

    Забавно!
    Много-много лет назад, для однокурсницы написал графический редактор который из bmp делал txt. Дальше она на файл натравливала нейросеть и что-то там делала.


  1. AirLight
    12.08.2021 02:57

    Почему практического применения нет? Очень даже есть. Бывают системы без графического интерфейса, типичный ssh-терминал, например. И они всегда будут. Если стандарт jpeg доработать, то можно вначале файла пихать в нем символьное представление. Это не сильно утяжелит файл.


    1. AirLight
      12.08.2021 02:57
      +1

      Или найти как в обычном jpeg в виде комментария такое вставлять.


    1. Error1024
      12.08.2021 06:09
      +2

      Зачем? Неужели усложнение стандартных форматов файлов картинок, которые итак чертовски сложны, стоит 0.1% гиков, которым «прикольного наблюдать картинку в терминале»


    1. mSnus
      12.08.2021 06:29
      +7

      Проще установить текстовый вьюер для графических файлов, чем менять формат


  1. usa_habro_user
    12.08.2021 07:25

    Скачал и проверил - круто, однако, "зачОт"!


  1. skvernoslov
    12.08.2021 10:33
    -1

    Есть ли готовая реализация на PHP?


    1. nikolaevevge
      13.08.2021 11:15

      Вы в серьёз собираетесь применить это на практике? Можете рассказать для чего например?


  1. Spectrum-Hyena
    12.08.2021 12:41
    -1

    Шрифты же у нас давно не квадратные, а прямоугольные, что видно на вытянутых ASCII. Точно не уверен, как сейчас, но у vga был режим знакоместа 8*16, т.е. 1 к 2, почему бы и тут не использовать схожие параметры?


    1. Fil Автор
      12.08.2021 12:48
      +2

      Вы ещё один, который подумал, что я сделал рендерер в ascii. Одни и те же символы предназначены и для визуализации и для декодера


      1. ArtRoman
        12.08.2021 19:58
        +1

        Стоило показать весь jpg-файл в блокноте, не всем очевидно что кроме самого текстового рисунка там больше ничего нет, это не rar-jpeg.

        Меня ещё отсутствие стандартного заголовка JFIF, который есть почти в каждой картинке. Узнал, что истинный заголовок jpg — это байты 0xFF 0xD8 0xFF.


        1. Fil Автор
          12.08.2021 23:11
          +1

          Метаданные все равно остаются. Это тоже может быть непонятно.

          JFIF в начале - это комментарий, там может быть что угодно. У вас 3-й байт 0xFF относится уже к следующему маркеру. Парсер пропускает все от данных (структрура которых задается маркером) до следующего маркера. У начального маркера данных нет, поэтому файл может начинаться например как 0xFF, 0xD8, "Hello world", 0xFF, 0xDB.


  1. KvanTTT
    12.08.2021 13:58
    +1

    Интересно, а можно ли подобным образом встроить исходный код, который тоже делал что-нибудь полезное по типу генерации этой же картинки?


    1. ArtRoman
      12.08.2021 20:04
      +1

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