Изучаем формат изображений Farbfeld

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

Добавить комментарий