В этой статье мы расскажем про самый простой бинарный формат изображений, который называется 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.


