В прошлой части я овладел техникой распаковки формата DEFLATE:
Эта техника понадобится для загрузки изображений в формате PNG, которую буду описывать здесь.
Файл PNG устроен просто: он состоит из блоков, где у каждого блока есть длина и тип. Если мы знаем тип блока и умеем/хотим его обрабатывать, то обрабатываем, если не умеем/не хотим, то просто пропускаем.
Поэтому задача в данном случае простая: нужно всего лишь распаковать блоки определённого типа, а остальные нас не интересуют. Для игры можно даже подготовить оптимизированные изображения, выкинув из них все ненужные блоки.
Попутно прокачаем работу с файлами в Rust, потому что, как нетрудно догадаться, нас будут ждать некоторые сюрпризы.
Открытие файла и сигнатура PNG
Файл открыть просто:
Как вы наверное помните, unwrap() разворачивает результат открытия файла, который может быть либо ошибкой, либо типом File. В случае ошибки программа просто упадёт.
Во многих языках при открытии файла указывают режим доcтупа: r – чтение, w – запись, a – дописывание в конец. В Rust сделано по-другому. Открытие файла с помощью File::open() это по умолчанию чтение. Чтобы открыть на запись, нужно создать файл с помощью метода File::create().
А чтобы открыть на добавление, надо использовать File::append()... обманули дурака на четыре кулака! Вот как правильно:
File::options().append(true).open("test.png")
Ладно, файл открыт, теперь нужно из него что-то прочитать. Первые 8 байт файла PNG должны быть такие:
137 80 78 71 13 10 26 10
На самом деле тут всё крайне продуманно. В ASCII байты выглядят так (цифровые коды – восьмеричные):
\211 P N G \r \n \032 \n
Первый байт 211 выбран, чтобы уменьшить вероятность ошибочного распознавания текстового файла как PNG (конечно, буржуям плевать на национальные алфавиты в ASCII – в кодировке CP866 получится Й). Кроме того, если передающая система очищает старший бит (какие-то отголоски древних времён), то данный байт сломается первым и просигнализирует об ошибке.
Далее идут буквы PNG как идентификатор формата. Затем перенос строки в виде символов \r\n. Он предназначен, чтобы отловить ошибки, когда передающая система исправляет переносы строк на другие, например на просто \n. Далее идёт код 032, это код Ctrl-Z, который при выводе в консоль MS-DOS остановит листинг файла (и кто сейчас работает в MS-DOS?) И наконец, ещё один перенос строки \n, который служит детектором обратной проблемы переноса строк, когда из одного символа делаются два.
Таким образом, если данный заголовок хоть где-то испортился, значит файл непригоден для использования.
Ладно, надо прочитать эти байты из файла, но как это сделать?
В языках вроде C есть функция fread(), куда передаётся указатель на буфер данных и количество байт, которые нужно прочитать в буфер. В Rust, насколько я смог выведать, такого подхода нет в принципе. Да, мы передаём буфер, но нужно заранее установить размер этого буфера.
Я буду использовать метод read_exact(), который читает ровно столько байт, сколько вмещается в буфер.
Я создал массив header размером 8 байт и передал его в read_exact(). Должно прочитаться ровно 8 байт. Затем прочитанное в массиве я сравнил с константой PNG_HEADER, которая тоже массив с предзаполненными байтами заголовка PNG. К счастью, сравнить их можно простым сравнением.
Пока что всё работает. Дальше нужно читать блоки из файла, но сразу возникает некоторое неудобство – я что, под каждое чтение должен готовить новый буфер с заранее определённой длиной? Как-то это странно, да и буфераы будут выделяться каждый раз на куче какими-то произвольными кусками, разве за это мы боролись?
Я бы хотел иметь один большой буфер-накопитель, куда можно последовательно считывать 4 байта, потом 8 байт, потом 100 байт, потом 6 байт и т.д. Но как это осуществить, если размер буфера каждый раз должен быть разным?
Куски
В Rust есть довольно удобный механизм нарезания массивов или векторов на т.н. слайсы (slice). Чтобы не допускать англицизмов, где это не требуется, по аналогии с нарезкой колбасы выберу слово "кусок".
Так вот, можно внутри массива выделить кусок, который начинается с какого-то индекса и имеет какую-то длину. И уже этот кусок использовать для чтения из файла. Что нам, собственно, и нужно.
Куски не требуют выделения дополнительной памяти под данные, копирования и т.д. Они практически бесплатны, создаются на стеке и состоят только из указателя и длины.
Так что я сейчас создам большой вектор buf с заранее заданной длиной, а потом выделю в нём кусок header длиной 8 байт и передам его в метод чтения:
Запись buf[..8] обозначает, что кусок берётся от начала буфера и до элемента с индексом 8, не включая его, то есть это элементы с индексами 0..7, а длина куска будет 8 элементов.
Обработка блока IHDR
Блоки в PNG имеют типы, которые задаются довольно остроумно. Тип это 4-байтное число, но байты подобраны так, чтобы из них получались некие читабельные символы, которые легко увидеть глазами, и они также мнемонически отражают назначение блока.
Кроме того, большие либо маленькие символы в типе отражают такие свойства блока, как обязательность/необязательность, приватность/публичность и т.п., что опять же сразу считывается глазами.
Самый первый блок в файле всегда IHDR – он содержит основную информацию об изображении.
Сделаю общую функцию для чтения блоков. Она должна получать ссылку на буфер, формировать кусок в буфере под нужную длину и считывать туда данные.
Возьмём от буфера кусок размером 8 байт и прочитаем туда данные. Эти 8 байтов содержат длину блока (4 байта) и тип блока (4 байта). 4 байта нужно преобразовать в 32-битное число.
Самый простой способ это взять байты и просто сложить их со сдвигами. Я сделаю отдельную функцию для удобства:
Обратим внимание, что в функцию передаётся вроде как эквивалент указателя char*, но на деле передаётся кусок, в котором зашита его длина, так что сделать что-то за пределами куска будет нельзя. Также обратим внимание, что байты расположены в порядке от старшего к младшему – так зафиксировано в спецификации PNG.
Ещё я сделаю структуру PNGChunk, описывающую блок, она содержит длину и тип (называется ctype, потому что type – зарезервированное слово):
Теперь можно дописать функцию чтения блока:
Обратим внимание, что для получения значения ctype из куска slice вырезается дополнительный кусок начиная с индекса 4 и до конца. Вырезать подкуски из кусков не возбраняется и нам ничего не стоит.
Данная функция возвращает только заголовок блока, а не сами его данные. Так как нужны только определённые данные, будем сначала проверять тип блока.
Самым первым блоком всегда должен быть IHDR, так что можно вынести его обработку отдельно перед всем остальным:
Если тип совпал, то нужно дальше прочитать содержимое блока IHDR:
Делаю кусок на длину блока + 4 и читаю.
Почему +4? Потому что в конце блока ещё 4 байта, которые отвечают за контрольную сумму CRC32, но я не буду её проверять, по крайней мере сейчас нет никакой надобности в этом. Но прочитать из файла эти 4 байта надо, чтобы дойти до следующего блока.
Далее из прочитанного куска я получу поля, описанные в блоке:
И надо проверить, подходящая ли это картинка:
Да, я не собираюсь поддерживать абсолютно все варианты PNG, нужен лишь самый ходовой.
Общие данные собраны, теперь надо прочитать оставшиеся блоки в файле. Нас будут интересовать только два типа блоков: IDAT, который содержит данные изображения, и IEND, который всё заканчивает. Всё остальное будем пропускать.
Сделаю цикл чтения блоков:
Позиция pos в буфере buf смещается, когда прочитаны данные очередного блока IDAT. Для всех остальных блоков смещается позиция чтения в файле с помощью file.seek(), а в буфер ничего не считывается.
Получился такой лог:
В файле много-много блоков IDAT по 8 килобайт. Вообще блок может иметь длину до 2^31, но их делают маленькими для того, чтобы картинки могли кодироваться и декодироваться в каких-то стеснённых условиях. Опять же для целевой ПК-платформы можно оптимизировать PNG так, чтобы в нём был лишь один большой блок IDAT и его не надо было собирать по кусочкам.
Теперь buf содержит данные с индекса 0 по индекс pos (не включая его).
Эти данные надо распаковать с помощью написанного ранее метода zlib::decompress() (я его оформил в отдельном модуле zlib):
let bytes = zlib::decompress(&buf[..pos]);
И вот мы наконец получили "сырые" данные картинки. Казалось бы, это уже готовые к использованию значения RGB(A), но нет, нас ждёт ещё одно испытание.
Дело в том, что перед сжатием в PNG ещё и фильтруются данные пикселов, чтобы они сжимались лучше. Причём фильтруется не сразу всё, а построчно. В каждой строке может быть свой тип фильтра, который лучше подходит.
Тип фильтра указан одним байтом в начале каждой строки, поэтому обработка происходит так: прочитать тип фильтра, затем читать оставшиеся байты строки и применять к ним фильтр.
Сами фильтры абсолютно несложные, они сводятся к тому, чтобы хранить не сами данные, а разницу между ними.
Предположим, PNG содержит горизонтальный градиент, тогда значения пикселов в нём будут условно идти так: 1, 2, 3, 4, 5...
Такой ряд будет плохо сжиматься, так как содержит уникальные значения. Если же мы вычислим разницу между ними и будем хранить её, то получим:
1, 1, 1, 1, 1...
Теперь они сожмутся отлично.
Таким же образом можно закодировать вертикальный градиент – будет считаться разница между байтами в соседних строках. Самый сложный фильтр это Paeth (по фамилии изобретателя) – он предсказывает, с каким из байтов по вертикали, горизонтали или диагонали берётся разница, то есть как бы объединяет в себе более примитивные фильтры.
Для фильтров уже есть готовые формулы, которые описаны в спецификации PNG и реализованы в коде на Питоне от того же хорошего человека, у которого я ранее позаимствовал распаковочный алгоритм.
Для расчётов понадобится переменная bpp (bytes per pixel) – количество байт в одном пикселе. Так как данная реализация поддерживает только ограниченное количество форматов цвета, это будет число 3 для RGB или 4 для RGBA. Далее вычисляется переменная stride – это количество байт в строке изображения.
Зная stride и ширину и высоту картинки, я могу создать массив уже известной длины под хранение пикселов изображения. Так как ширина, высота и прочие параметры относятся к одному изображению, я сделаю структуру Image:
И могу её инициализировать с нужными данными:
Далее нужно в цикле перебирать строки и применять к ним фильтры. Но мне не понравилась реализация на Питоне по вышеуказанной ссылке, она слишком лобовая, хотя и наиболее понятная.
Я немного оптимизирую и буду применять фильтр не к каждому байту, а ко всей строке. Это не параллельные вычисления, просто аргументом для фильтра будет не байт, а сразу строка и он её будет сам оптимизированно перебирать.
Я сделаю для удобства класс Filter, в котором будут определены методы для каждого типа фильтра, и также будет храниться текущая позиция записи в выходной массив.
На иллюстрации показаны только методы none() и sub(), чтобы не загромождать листинг.
И если б вы знали, насколько тяжко работать с арифметическими действиями над целыми числами в Rust! Программа падает из-за переполнений, отрицательных результатов и прочих нормальных для машинных кодов вещей.
Мне пришлось вставить вот такое в Cargo.toml, чтобы вычисления вели себя нормально:
[profile.dev]
overflow-checks = false
Теперь будем перебирать распакованные данные построчно. Получаем первый байт строки, определяем тип фильтра и применяем фильтр ко всей строке.
Как закрыть файл?
Оказывается, закрывать файл в Rust не надо. Он закроется сам, когда закончится время жизни переменной, которая им владеет. Однако остаётся неясным вопрос, а что, если время жизни у переменной не закончилось, а я уже хочу закрыть файл? Для этого можно использовать:
std::mem::drop(file);
Что, однако, не закроет файл, а просто принудительно закончит время жизни переменной, что и приведёт к закрытию файла.
Заключение
Изображение PNG прочитано. В данной реализации мне не нравится то, что данные изображения нужно размещать в памяти трижды: сначала блоки IDAT, потом распакованные данные, и наконец отфильтрованные данные.
Пути оптимизации существуют, но эту статью я должен уже прекратить, как ремонт. Пока программа поживёт и так. Кроме того, я распаковал PNG, но ещё не вывел ничего на экран и поэтому даже не знаю, будет ли всё работать.
Я оформил zlib и png в виде модулей, а исходный код можно посмотреть на гитхабе в ветке part5:
Читайте дальше: