В этой статье мы расскажем про самый простой бинарный формат изображений, который называется Farbfeld. Такой формат не очень известен широкому кругу пользователей, однако, многим наверняка известен проект в котором разработали Farbfeld – это проект suckless-tools. Этот проект славится разработкой интересных и компактных инструментов и старается создавать программы, которые следуют традициям UNIX. Команда suckless-tools пыталась разработать максимально простой формат для хранения изображений, который было бы легко обрабатывать в стиле UNIX (т.е. применять к нему стандартные утилиты UNIX в стиле поточной обработки) и который мог бы стать удобным промежуточным форматом. На наш взгляд разработчики достигли своих целей, и мы покажем как можно с минимальным усилиями реализовать компактную библиотеку для работы с Farbfeld в стиле ppmformats. Помимо этого, покажем как начать знакомство с этим форматом и подготовить минимальный набор инструментов для работы с изображениями Farbfeld.
Структура Farbfeld
Прежде всего, Farbfeld – это бинарный формат изображений, а это значит, что для работы с такими файлами потребуется считывание/запись двоичных данных. Сам Farbfeld устроен достаточно просто и имеет в своем составе несколько секций (полей), между которыми нет никаких разделителей: “магическое число” (сигнатура формата, опознавательный знак того, перед нами именно Farbfeld), длина и ширина изображения, блок данных с пикселями.
Fun fact! Кстати, в переводе с немецкого Farbfeld означает буквально “цветовое поле”.
Отсутствие разделителей компенсируется фиксированным размером почти каждого поля в байтах, что приводит нас к следующему представлению Farbfeld-файла:
Вид секции | Размер в байтах |
Магическое число (слово farbfeld в виде байтового массива) | 8 |
Длина изображения (32-битное значение, записанное в виде массива байтов с порядком Big Endian) | 4 |
Ширина изображения (32-битное значение, записанное в виде массива байтов с порядком Big Endian) | 4 |
Массив пикселей (4 16-битных значения RGBA, записанные в виде массива байтов с порядком Big Endian) | 8 |
Из схемы формата практически очевидна его внутренняя структура, по которой можно будет разработать схему кодирования/декодирования, однако необходимо рассмотреть каждый компонент Farbfeld по отдельности.
“Магическое число” или сигнатура формата для Farbfeld выглядит как обычная строка, содержащая название формата, записанное маленькими латинскими буквами в кодировке ASCII. Получается, что любой Farbfeld-файл начинается со строки farbfeld
, которая легко опознается и не подвергается никакому дополнительному кодированию. Следующие два отдельных поля устроены одинаково, и кодируют длину и ширину изображения в пикселях соответственно. Длина и ширина представляют собой обычные 32-битные числа (без знака), но вот записываются оба числа в виде массива байтов. Более того, байты этого массива записываются в порядке, который называется Big-Endian (или порядок “от старшего байта к младшему”), к примеру, 32-битное число без знака (в терминологии D, uint) 0x01abcdef выглядело бы как массив [0x01, 0xab, 0xcd, 0xef].
После данных о линейных размерах изображения идут данные о пикселях изображения. Каждый пиксель представлен 4-мя 16-битными значениями (в терминологии D, ushort); каждое из этих значений соответствует одному из компонентов цвета пикселя в системе sRGB и без гамма-коррекции: красная компонента (red, R), зеленая компонента (green, G), синяя компонента (blue, B) и компонента прозрачности (alpha, A). Таким образом, получается что каждый пиксель представлен цветом в формате RGBA, а диапазон изменения каждого из компонентов от 0 до 65535 (т.е. от 0 до (2 ^^ 16) – 1, поскольку каждый из компонентов 16-битный). Также, как и длина/ширина изображения, 16-битные значения RGBA хранятся в виде массивов байтов, каждый массив хранится в порядке “от старшего к младшему”).
Важным моментом является и тот факт, что пиксельная информация в файле храниться построчно, т.е. сначала идут пиксели первой строки изображения, потом пиксели второй строки и т.д.
Реализация Farbfeld на D
Исходя из вышеописанной структуры Farbfeld, для его реализации потребуются: реализация цвета в формате RGBA, реализация структуры данных под картинку Farbfeld и ряд вспомогательных функций/шаблонов, а также исходный код библиотеки ppmformats, который возьмем за основу для реализации структур данных формата Farbfeld. Начнем со вспомогательных шаблонов и функций, которые потребуются для добавления нужных сеттеров/геттеров в классы для цвета и изображения, а также функций конструирования/деконструирования значений в определенном порядке байтов:
private { import std.algorithm; import std.conv; import std.math; import std.stdio; import std.string; template addProperty(T, string propertyName, string defaultValue = T.init.to!string) { const char[] addProperty = format( ` private %2$s %1$s = %4$s; void set%3$s(%2$s %1$s) { this.%1$s = %1$s; } %2$s get%3$s() { return %1$s; } `, "_" ~ propertyName.toLower, T.stringof, propertyName, defaultValue ); } enum BYTE_ORDER { LITTLE_ENDIAN, BIG_ENDIAN } T buildFromBytes(T)(BYTE_ORDER byteOrder, ubyte[] bytes...) { T mask; size_t shift; foreach (i, e; bytes) { final switch (byteOrder) with (BYTE_ORDER) { case LITTLE_ENDIAN: shift = (i << 3); break; case BIG_ENDIAN: shift = ((bytes.length - i - 1) << 3); break; } mask |= (e << shift); } return mask; } auto buildFromValue(T)(BYTE_ORDER byteOrder, T value) { ubyte[] data; T mask = cast(T) 0xff; size_t shift; foreach (i; 0..T.sizeof) { final switch (byteOrder) with (BYTE_ORDER) { case LITTLE_ENDIAN: shift = (i << 3); break; case BIG_ENDIAN: shift = ((T.sizeof - i - 1) << 3); break; } data ~= cast(ubyte) ( (value & (mask << shift)) >> shift ); } return data; } }
Доработанный шаблон addProperty
был честно взят из нашей ppmformats, а вот последующие функции были написаны специально для работы с Farbfeld, хотя могут применяться и для других целей. Функция buildFromBytes
принимает два аргумента – порядок байтов (либо Big-Endian, либо Little-Endian) и массив байтов, а возвращает некое значение, тип которого указывается шаблонным параметром, сконструированное из поданных байтов (которые могут быть не только массивом байтов, но и отдельными аргументами типа ubyte) с учетом указанного порядка следования байтов. Функция buildFromValue
является обратной для buildFromBytes и принимает также два аргумента: порядок байтов и некое значение для деконструкции в байты, а возвращает массив байтов с нужным порядком следования. Две описанные процедуры очень удобны для создания и разбора различного рода форматов, поскольку обладают некоторой универсальностью. Далее, возьмем класс RGBColor
из ppmformats и немного модифицируем его, добавив дополнительную компоненту цвета – альфа-канал и расширив диапазон принимаемых значений с [0, 255] до [0, 65535]; полученный класс будет выглядеть следующим образом:
class RGBAColor { mixin(addProperty!(int, "R")); mixin(addProperty!(int, "G")); mixin(addProperty!(int, "B")); mixin(addProperty!(int, "A")); this(int R = 0, int G = 0, int B = 0, int A = 0) { this._r = R; this._g = G; this._b = B; this._a = A; } const float luminance709() { return (_r * 0.2126f + _g * 0.7152f + _b * 0.0722f); } const float luminance601() { return (_r * 0.3f + _g * 0.59f + _b * 0.11f); } const float luminanceAverage() { return (_r + _g + _b) / 3.0; } alias luminance = luminance709; override string toString() { return format("RGBAColor(%d, %d, %d, %d, I = %f)", _r, _g, _b, _a, this.luminance); } RGBAColor opBinary(string op, T)(auto ref T rhs) { return mixin( format(`new RGBAColor( clamp(cast(int) (_r %1$s rhs), 0, 65535), clamp(cast(int) (_g %1$s rhs), 0, 65535), clamp(cast(int) (_b %1$s rhs), 0, 65535), clamp(cast(int) (_a %1$s rhs), 0, 65535) ) `, op ) ); } RGBAColor opBinary(string op)(RGBAColor rhs) { return mixin( format(`new RGBAColor( clamp(cast(int) (_r %1$s rhs.getR), 0, 65535), clamp(cast(int) (_g %1$s rhs.getG), 0, 65535), clamp(cast(int) (_b %1$s rhs.getB), 0, 65535), clamp(cast(int) (_a %1$s rhs.getA), 0, 65535) ) `, op ) ); } }
Аналогичным образом дорабатываем класс PixMapFile
для работы с типом RGBAColor и напрямую добавляем в него два очень необходимых метода для работы с изображениями – load
/save
, переименуем его в FarbfeldImage
:
class FarbfeldImage { mixin(addProperty!(uint, "Width")); mixin(addProperty!(uint, "Height")); private { RGBAColor[] _image; auto actualIndex(size_t i) { auto S = _width * _height; return clamp(i, 0, S - 1); } auto actualIndex(size_t i, size_t j) { auto W = cast(size_t) clamp(i, 0, _width - 1); auto H = cast(size_t) clamp(j, 0, _height - 1); auto S = _width * _height; return clamp(W + H * _width, 0, S); } } this(uint width = 0, uint height = 0, RGBAColor color = new RGBAColor(0, 0, 0, 0)) { this._width = width; this._height = height; foreach (x; 0.._width) { foreach (y; 0.._height) { _image ~= color; } } } RGBAColor opIndexAssign(RGBAColor color, size_t x, size_t y) { _image[actualIndex(x, y)] = color; return color; } RGBAColor opIndexAssign(RGBAColor color, size_t x) { _image[actualIndex(x)] = color; return color; } RGBAColor opIndex(size_t x, size_t y) { return _image[actualIndex(x, y)]; } RGBAColor opIndex(size_t x) { return _image[actualIndex(x)]; } override string toString() { string accumulator = "["; foreach (x; 0.._width) { string tmp = "["; foreach (y; 0.._height) { tmp ~= _image[actualIndex(x, y)].toString ~ ", "; } tmp = tmp[0..$-2] ~ "], "; accumulator ~= tmp; } return accumulator[0..$-2] ~ "]"; } alias width = getWidth; alias height = getHeight; final RGBAColor[] array() { return _image; } final void array(RGBAColor[] image) { _image = image; } final void changeCapacity(uint x, uint y) { long newLength = (x * y); if (newLength > _image.length) { auto restLength = cast(long) newLength - _image.length; _image.length += cast(size_t) restLength; } else { if (newLength < _image.length) { auto restLength = cast(long) _image.length - newLength; _image.length -= cast(size_t) restLength; } } _width = x; _height = y; } void load(string filename) { File file; file.open(filename, `rb`); // magic number is `farbfeld` (field size: 8 bytes) auto magicNumber = new void[8]; file.rawRead!void(magicNumber); // image width (field size: 4 bytes) and image height (field size: 4 bytes) auto imageSizes = new ubyte[8]; file.rawRead!ubyte(imageSizes); _width = buildFromBytes!uint(BYTE_ORDER.BIG_ENDIAN, imageSizes[0..4]); _height = buildFromBytes!uint(BYTE_ORDER.BIG_ENDIAN, imageSizes[4..$]); _image = []; foreach (i; 0.._width) { foreach (j; 0.._height) { auto pixel = new ubyte[8]; file.rawRead!ubyte(pixel); auto R = buildFromBytes!ushort(BYTE_ORDER.BIG_ENDIAN, pixel[0..2]); auto G = buildFromBytes!ushort(BYTE_ORDER.BIG_ENDIAN, pixel[2..4]); auto B = buildFromBytes!ushort(BYTE_ORDER.BIG_ENDIAN, pixel[4..6]); auto A = buildFromBytes!ushort(BYTE_ORDER.BIG_ENDIAN, pixel[6..$]); _image ~= new RGBAColor(R, G, B, A); } } }
Код, за исключением load
/save
, почти полностью повторяет код из ppmformats и уже был рассмотрен в статье про нашу библиотеку, а вот про два последних метода мы расскажем отдельно.
Метод load
работает следующим образом: сначала открывается файл на считывание в бинарном режиме (поскольку перед нами бинарный файл), после чего из читаются “сырые данные” в массив void[8]
, эти данные соответствуют “магическому числу” формата Farbfeld (тип void[]
это один из “универсальных” типов для хранения чего угодно в языках C/C++/D, но с ним надо обращаться осторожно и использовать прямое приведение к нужному типу впоследствии) и которые просто игнорируются (но это можно использовать для проверки корректности загружаемого файла), далее считываются “сырые данные” для длины и ширины изображения, извлекаются их значения из полученного массива байтов с помощью buildFromBytes
и записываются в нужные переменные, очищается массив пикселей изображения. Полученные значения длины и ширины используются далее в цикле для извлечения из остатка файла “сырой информации” о пикселях и разбора выделенных массивов байтов с помощью функции buildFromBytes
в структуру данных RGBAColor
.
Метод save
работает на основе тех же идей: открывается файл на запись в бинарном режиме, далее записывается “магическое число” простой записью строки в файл, после чего записываются данные о длине/ширине изображения: сначала с помощью buildFromValue
значения длины/ширины деконструируются в набор байтов, которые приводятся в массив символов, пригодный для дальнейшей записи в файл. Схожим образом, с помощью декомпозиции в байтовый массив с последующим приведением к символьному, записываются данные по пикселям, только в этом случае применяется обход каждого пикселя изображения в цикле. На этом реализация Farbfeld формата заканчивается и можно приступить к испытаниям, однако, перед этим необходимо произвести некоторые подготовительные действия.
Тестирование и утилиты для него
Первым делом нам потребуется пример изображения в формате Farbfeld и также программа, которая может просматривать такие файлы. С примером изображения нам не повезло: не удалось нигде найти экспериментальный образец картинки, поэтому пришлось создать его самим.
Чтобы создать экспериментальную картинку нужно воспользоваться уже готовым решением от команды suckless-tools, а именно программой которая умеет конвертировать изображения из уже известного формата в формат Farbfeld. А для этого нужно скомпилировать конвертер, что можно сделать следующим образом:
git clone https://git.suckless.org/farbfeld
make
Сборка очень быстрая и выглядит примерно так:
Из скриншота видно, что конвертеров больше одного и каждый конвертер работает со своим форматом. Для наших экспериментов подойдет конвертер png2ff, который преобразует PNG-файл в Farbfeld-файл и запуск которого с уже известным стандартным изображением Lenna.png в нашем случае осуществляется следующим образом:
./png2ff < Lenna.png > Lenna.ff
После этого нужно скомпилировать просмотрщик файлов Farbfeld, который называется lel. Осуществить сборку можно следующими командами:
git clone git://git.codemadness.org/lel
make
make install
Сборка также проходит моментально:
Просмотреть полученный файл Lenna.ff можно с помощью lel следующим образом:
./lel Lenna.ff
Испытание формата Farbfeld можно провести таким образом – загрузить файл картинки Farbfeld, нарисовать простенькую диагональную линию и выгрузить назад в формате Farbfeld, после чего открыть картинку на просмотр в lel. Если lel сумеет открыть результирующий файл, значит, процедуры работы с новым форматом работают корректно. Код для вышеуказанного испытания:
void main() { FarbfeldImage ff = new FarbfeldImage; ff.load(`/home/aquareji/Templates/Lenna.ff`); ff.width.writeln; ff.height.writeln; foreach (i; 0..128) { ff[i, i] = new RGBAColor(65535, 65535, 65535, 65000); } ff.save(`/home/aquareji/Templates/Lenna_test.ff`); }
Результат:

Как видите, работает!
Последняя версия нашей библиотеки доступна в dub или по ссылке:
https://github.com/aquaratixc/farbfelded.
FAQ по Farbfeld: https://tools.suckless.org/farbfeld/faq.
P.S: Поскольку Farbfeld разрабатывался максимально простым и в точном соответствием с философией UNIX, то можно использовать традиционные утилиты UNIX для работы с Farbfeld. Вот смотрите сами: формат Farbfeld не предполагает наличия сжатия, однако, он спроектирован так, что некоторые кодовые последовательности из значений пикселов легко обнаруживаются в словарях программ-архиваторов, таких как например bzip2. Это означает, что можно легко получить и сжатый Farbfeld, допустим вот так:
./png2ff < Lenna.png | bzip2 > image.ff.bz2
При этом для сравнения, исходный файл Lenna.png занимает 174.4 Кб; Lenna.ff – 2.0 Мб; image.ff.bz2 – 155,6 Кб. Результирующий файл этой операции, а также файлы Lenna.ff и Lenna_test.ff можно найти по ссылке:
https://github.com/LightHouseSoftware/files_for_articles/tree/master/farbfeld.