Библиотека ppmformats. Чему я научился создавая обработчики PortablePixmap форматов

Недавно, я понял, что довольно часто я работаю с D не с домашнего компьютера, а с обычного смартфона. На смартфоне у меня стоит почти та же среда, что и на компьютере (я имею в виду, что на компьютере и смартфоне одинаковый комплект консольных утилит и их настройки так же одинаковы), а это значит, что хочется единообразной работы кода на столь разных платформах. Добившись успеха с некоторыми из примеров блога, я решил попытать счастья с обработкой изображений на смартфоне, поскольку довольно часто источником графических данных является именно смартфон…

Но, тут меня ждала неудача: dlib отказался работать на смартфоне (что привело к моему первому pull request в репозиторий dlib), а остальные библиотеки не так удобны и точно также отказываются работать в Termux на смартфоне. При этом срочно нужно организовать работу с изображениями и сделать это так, чтобы удобство было на уровне dlib, а весь остальной функционал мог бы работать с минимальным рабочим окружением и не требовал бы сторонних библиотек.

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

Времени на разбирательство с форматами PNG, JPG и подобными у меня не было, а потому пришлось вспомнить про портативные пиксельные изображения (так переводиться описание файла изображения с расширением PPM) и про то, что весь код для обработки такого формата уже описан и готов к работе. В принципе, можно было бы удовлетвориться кодом из наших статей, однако, большинство примеров кода с обработкой картинок задействуют dlib и напрямую используют ее интерфейс. Поэтому, пришлось взяться за разработку, добавив в список требований к заданию интерфейс, похожий на таковой у dlib…

Как обычно в своей библиотеке, я использован шаблон для автоматической генерации сеттеров/ геттеров, однако, я смог его еще раз усовершенствовать, добавив возможность указания значения по умолчанию для переменной свойства:

import std.conv;
template addProperty(T, string propertyName, string defaultValue = T.init.to!string)
	{
		import std.string : format, toLower;
	 
		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
			);
	}

Изменился немного сам шаблон — теперь необходимо указывать имя свойства с большой буквы, а внутренняя приватная переменная свойства будет начинаться со знака подчеркивания и название будет уже с маленькой буквы. И да: шаблон используется повсеместно в этой библиотечке.

Теперь, недолго думая я описываю цвет в формате RGB в виде обычного класса с вполне понятными параметрами конструктора (по умолчанию, все эти три параметра инициализируются нулями), для которых генерируются сеттеры/геттеры (в общем, аксессоры) с помощью вышеупомянутого шаблона. Также, обращаю внимание на то, что значения для R, G и B являются целочисленными (и не в виде ubyte, поскольку еще надо и арифметику реализовать и без ее переполнения) и находятся в диапазоне от 0 до 255. Данное решение принято специально для упрощения последующих работ с цветами и не только для этого.

В описании цвета присутствуют также два описания для вычисления яркости (или иначе говоря, интенсивности) цвета, причем в их именовании стоит имя стандарта по ITU.R (надеюсь, не ошибся в описании), а на одно из них стоит просто псевдоним, который позволяет вызвать метод как luminance. Так сделано потому, что это значение используется очень часто в алгоритмах цифровой обработки изображений, и было бы кощунственным испортить настолько интуитивный и важный элемент интерфейса, оставив лишь именования процедур вычисления интенсивности по стандарту.

Помимо этого, я реализовал минимальный комплект арифметики с цветами RGB использовав mixin, как было описано в статье про перегрузку операторов, но добавил волшебную процедуру clamp из std.algorithm, которая позволяет практически без напряжения втиснуть любое значение в заданный диапазон (в нашем случае, от 0 до 255). Плюсом к этому будет неплохой вывод цвета как значения в терминал: здесь цвет RGB выводится в удобном виде с описанием каждого его компонента с добавлением дополнительного значения яркости в том виде, в каком оно используется в программах.

Уже это дает неплохое подспорье для дальнейшей реализации всей механики форматов портативных пиксельных изображений, смотрите сами:

class RGBColor
{
	mixin(addProperty!(int, "R"));
	mixin(addProperty!(int, "G"));
	mixin(addProperty!(int, "B"));

	this(int R = 0, int G = 0, int B = 0)
	{
		this._r = R;
		this._g = G;
		this._b = B;
	}

	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);
	}

	alias luminance = luminance709;

	override string toString()
	{
		import std.string : format;
		
		return format("RGBColor(%d, %d, %d, I = %f)", _r, _g, _b, this.luminance);
	}

	RGBColor opBinary(string op, T)(auto ref T rhs)
	{
		import std.algorithm : clamp;
		import std.string : format;

		return mixin(
			format(`new RGBColor( 
				clamp(cast(int) (_r  %1$s rhs), 0, 255),
				clamp(cast(int) (_g  %1$s rhs), 0, 255),
				clamp(cast(int) (_b  %1$s rhs), 0, 255)
				)
			`,
			op
			)
		);
	}

	RGBColor opBinary(string op)(RGBColor rhs)
	{
		import std.algorithm : clamp;
		import std.string : format;

		return mixin(
			format(`new RGBColor( 
				clamp(cast(int) (_r  %1$s rhs.getR), 0, 255),
				clamp(cast(int) (_g  %1$s rhs.getG), 0, 255),
				clamp(cast(int) (_b  %1$s rhs.getB), 0, 255)
				)
			`,
			op
			)
		);
	}
}

Имея тип RGBColor, я создаю реализацию для класса PixMapImage, который представляет собой тип-обертку над массивом элементов типа RGBColor и является представлением для обычного изображения. PixMapImage включает в себя удобные аксессоры для длины и ширины изображения (getWidth, к примеру, извлечет длину изображения; setWidth ее изменит), а также удобные псевдонимы для соответствующих геттеров (фигурируют под именами width и height соответственно, и повсеместно используются в библиотеке). Также, данный класс имеет: удобный конструктор с параметрами по умолчанию (позволяют придать изображению необходимые размеры и начальный цвет для заполнения), методы извлечения элементов (т.е пикселей типа RGBColor) по одному (!) или по двум индексам, а также соответствующие методы задания необходимых значений конкретным пикселям (тоже в двух вариантах: по одному или по двум индексам), метод для прямого извлечения внутреннего массива (называется array) и перегруженный toString (может дать описание хранимой картинке в виде двумерного массива, т.е матрицы, несмотря на то, что внутренний массив одномерен).

Класс PixMapImage представлен следующим кодом:

class PixMapImage
{
	mixin(addProperty!(int, "Width"));
	mixin(addProperty!(int, "Height"));
	
	private
	{
		RGBColor[] _image;

		import std.algorithm : clamp;

		auto actualIndex(size_t i)
		{
			auto S = _width * _height;
		
			return clamp(i, 0, S);
		}

		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(int width = 0, int height = 0, RGBColor color = new RGBColor(0, 0, 0))
	{
		this._width = width;
		this._height = height;

		foreach (x; 0.._width)
		{
			foreach (y; 0.._height)
			{
				_image ~= color;
			}	
		}
	}

	RGBColor opIndexAssign(RGBColor color, size_t x, size_t y)
	{
		_image[actualIndex(x, y)] = color;
		return color;
	}

	RGBColor opIndexAssign(RGBColor color, size_t x)
	{
		_image[actualIndex(x)] = color;
		return color;
	}

	RGBColor opIndex(size_t x, size_t y)
	{
		return _image[actualIndex(x, y)];
	}

	RGBColor 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 RGBColor[] array()
	{
		return _image;
	}

	// experimental feature (!)
	void changeCapacity(int x, int y)
	{
		auto newLength = (x * y);
		
		if (newLength > _image.length)
		{
			auto restLength = newLength - _image.length;
			_image.length += restLength;
		}
		else
		{
			if (newLength > _image.length)
			{
				auto restLength = _image.length - newLength;
				_image.length -= restLength;
			}
		}
		_width = x;
		_height = y;
	}
}

Как я сказал в примечании, внутренний массив одномерен, поэтому все манипуляции с присвоением/извлечением значений производятся с помощью двух хитрых приватных методов, позволяющих точно вычислить «реальный индекс» в одномерном массиве и осуществляющих привязку вычисленного индекса к четким границам внутреннего массива. Также, из-за довольно глупой реализации начального заполнения массива в конструкторе, есть принципиальная вещь, которую необходимо помнить при работе с библиотекой: если вдруг произошли изменения размеров картинки, то их нужно осуществить явно с помощью очень экспериментального метода changeCapacity, который принимает новую длину и ширину для изображения. Также, вы не сможете изменить длину и ширину путем использования методов setWidth и setHeight: длина и ширина изменяться только для индексатора вычисляющего «реальный индекс», но не для внутреннего массива (впоследствии я это исправлю перехватом этих методов, просто потому что не успел это сделать ранее — слишком много работы было…), и поэтому следует воспользоваться методом changeCapacity, который автоматически изменит нужные параметры.

(Кстати, забыл упомянуть: changeCapacity изменяет внутренний массив не только в сторону увеличения, но и всторону уменьшения. Имейте также в виду, что если размер изображения увеличивается, то оставшееся пространство картинки будет заполнено цветом по умолчанию, а именно — черным)

После определения базового носителя изображения неплохо было бы определить список для «магических чисел» (т.е сигнатур формата) для всех представителей формата Portable AnyMap, коих насчитывается ровно 6: три текстовых формата и три бинарных. Для этого я создал особое перечисление, которое содержит удобное обозначение формата (обозначение состоит из сокращенного имени формата и его вида: текстовой или бинарный):

enum PixMapFormat : string
{
	PBM_TEXT 	= 	"P1",
	PBM_BINARY 	=  	"P4",
	PGM_TEXT 	= 	"P2",
	PGM_BINARY	=	"P5",
	PPM_TEXT	=	"P3",
	PPM_BINARY	= 	"P6",
}

И вот тут я первый раз подорвался на мине: оказывается, просто так получить значение из перечисления не получится и приведение типа тут тоже не сработает ! А именно значение и будет интересовать нас в дальнейшем !

Проблема легко решается, если член перечисления присвоить переменной соответственно типу его значения, и это самое простое решение. Простое, но не универсальное и потому оно обернуто вот в такой шаблон, использующий OriginalType из std.traits:

auto EnumValue(E)(E e) 
		if(is(E == enum)) 
	{
		import std.traits : OriginalType;
		OriginalType!E tmp = e;
		return tmp; 
	}

На общее решение меня навел пост с форума на официальном сайте D, с которым вы можете ознакомится в этой теме форума.

Теперь, можно приступать к реализации конкретных форматов изображений.

Тут стоит заметить, что каждый формат имеет свою сигнатуру (ее уже описали с помощью перечисления), два числа на новой строке, идущие через пробел (длина и ширина картинки), максимальное возможное значение для отдельного компонента RGB (отсутствует в форматах P4 и P1, поскольку это двухцверные изображения, содержащие только черный и белый цвета) и информация о пикселях в текстовом или бинарном виде. Процедура чтения/записи такого изображения с диска в общем виде одинакова и представляет собой чтение/запись уже описанных частей формата, поэтому обобщенный тип файла изображения (он также будет служить и контейнером под картинку, т.е к нему можно будет обращаться как к изображению, но в то же время можно будет загружать и сохранять) можно представить в виде некоего класса без конструктора (очень удобно, когда нужен будет общий тип под изображение) внутри которого содержатся заголовок файла (т.е «магическое число», сигнатура формата), само изображение в виде PixMapImage, указатель на файл, а также два вполне стандартных метода save/load.

Так я пришел к идее создания класса PixMapFile, который выплощает в себе все описанное в абзаце выше и выглядит следующим образом:

class PixMapFile
{
	mixin(addProperty!(PixMapImage, "Image"));
	protected
	{
		File _file;
		PixMapFormat _header;

		abstract void loader();
		abstract void saver();
	}

	void load(string filename)
	{
		with (_file)
		{
			open(filename, `r`);

			if (readln.strip == EnumValue(_header))
			{
				auto imageSize = readln.split;
				auto width = imageSize[0].parse!int;
				auto height = imageSize[1].parse!int;

				_image = new PixMapImage(width, height);

				loader;
			}
		}
	}
	
	void save(string filename)
	{
		with (_file)
		{
			open(filename, "w");
			writeln(EnumValue(_header));
			writeln(_image.width, " ", _image.height);

			saver;
		}
	}	

	final PixMapImage image() 
	{ 
		return _image; 
	}

	alias image this;
}

Данный класс не предназначен для конкретного воплощения, а будет использован, как суперкласс, а потому в нем имеются два абстрактных метода loader и saver, которые должны будут быть реализованы внутри субклассов и именно эти методы вызываются внутри обобщенных процедур load/save у конкретных классов. Также, класс имеет финальную реализацию метода image, которая по запросу выдает спрятанный внутри класса контейнер с изображением.

Отдельно обращаю внимание на то, что идет следом за описанием финальной (модификатор final) версии метода image, а именно конструкцию, которая реализует субтипирование в D: alias image this. Эта конструкция стала моим спасением после подрыва на второй мине: изначально, псевдоним выглядел как alias _image this, но это приводило к тому, что после импорта библиотеки, псевдоним отказывался работать и в итоге методы, существующие у _image не подхватывались, что приводило к серьезным ошибкам при компиляции…

Это обстоятельство с изменением псевдонима немного притормозило работу над библиотекой (искал решение с проблемой импорта, в итоге пришлось спросить на том же официальном форуме), но работающее субтипирование перекрыло все: теперь у меня в руках была возможность одной строкой осуществить добавление всех методов из класса PixMapImage в класс PixMapFile, что впоследствии могло быть использовано и в классах, которые реализуют конкретные форматы !

Следующим важным шагом стало мое первое использование mixin template для отказа от ручного прописывания конструкторов любому наследнику класса PixMapFile. Не то что бы мне лень прописывать конструктор для каждого из потомков класса PixMapFile, однако, там есть две очень важные детали: автоматический подхват нужного члена перечисления с описанием типа формата (а также и заголовка соответствующего файла) и автомагическое (!) добавление псевдонима alias image this, про которое лучше не забывать (последствия такой забывчивости фатальны — не будут подхвачены методы контейнера PixMapImage, что приведет к катастрофическим последствиям) !

Итак, код «примеси»:

mixin template addConstructor(alias pmf)
{
	this(int width = 0, int height = 0, RGBColor color = new RGBColor(0, 0, 0))
	{
		_image  = new PixMapImage(width, height, color);
		_header = pmf; 
	}

	alias image this;
}

Имея такую «примесь» (именно так называется в некоторых языках эта штука) и помня про то, что надлежит реализовать методы loader и saver, можно приступать к реализации конкретных форматов, для чего надлежит осуществить наследование от PixMapFile и применить «подмешивание примеси» в полученный класс с одновременной подачей параметра, подсказывающего то, какой именно формат мы хотим реализовать (в этом помогает перечисление).

К примеру, знакомый нам формат PPM, а именно P6 выглядит в библиотеке вот так:

class P6Image : PixMapFile
{
	mixin(addProperty!(int, "Intensity", "255"));
	mixin addConstructor!(PixMapFormat.PPM_BINARY);

	override void loader()
	{
		auto data = _file.readln;
		_intensity = data.parse!int;

		auto buffer = new ubyte[width * 3];
		
		for (uint i = 0; i < height; i++)
		{
		 	_file.rawRead!ubyte(buffer);
						
		    for (uint j = 0; j < width; j++) { auto R = buffer[j * 3]; auto G = buffer[j * 3 + 1]; auto B = buffer[j * 3 + 2]; _image[j, i] = new RGBColor( (R > _intensity) ? _intensity : R,
					(G > _intensity) ? _intensity : G,
					(B > _intensity) ? _intensity : B
				);
		    } 
		}
	}

	override void saver()
	{
		_file.writeln(_intensity);

		foreach (e; _image.array)
		{
			auto R = e.getR;
			auto G = e.getG;
			auto B = e.getB;

			auto rr = (R > _intensity) ? _intensity : R;
			auto gg = (G > _intensity) ? _intensity : G;
			auto bb = (B > _intensity) ? _intensity : B;

			_file.write(
				cast(char) rr,
				cast(char) gg,
				cast(char) bb
		    );
	    }
	}
}

Конечно, когда я задумывал реализацию своей библиотеки для базовой работы с изображениями (называется она кстати ppmformats и доступна в реестре dub и на GitHub), я совершенно не представлял какие меня ожидают приключения и что мне предстоит реализовать шесть разных форматов, в чем-то похожих и при этом совершенно разных. И я уж точно не думал, что мне удастся скопировать большую часть интерфейса модуля dlib.image из библиотеки dlib (представьте, я сделал даже функцию image, которая может создать изображение в любом из шести описанных форматов от P1 до P6 !), однако, вот чему я научился по итогам создания ppmformats:

  • использовать template mixin;
  • извлекать значения из enum;
  • использовать субтипирование и не терять его возможности при импортах;
  • создавать свои мощные абстракции и собственные универсальные шаблоны;
  • и многое многое другое…

 

А теперь посмотрите сюда… Вот, к примеру, реализация выборочного обесцвечивания в dlib, а вот так выглядит версия алгоритма с применением моей ppmformats:

import ppmformats;
auto excludeAnotherColor(PixMapFile source, RGBColor color, float distance = 0.51f)
{
	auto imageWidth = source.width;
	auto imageHeight = source.height;
	auto simg = image(imageWidth, imageHeight);
 
 
	float colorDistance(RGBColor first, RGBColor second)
	{
		import std.math : sqrt;
 
		float R = (first.getR - second.getR) ^^ 2;
		float G = (first.getG - second.getG) ^^ 2;
		float B = (first.getB - second.getB) ^^ 2;
 
		return sqrt(R + G + B);
	}
 
	
	for (int i = 0; i < imageWidth; i++)
	{
		for (int j = 0; j < imageHeight; j++)
		{
			auto currentPixel = source[i,j];
 
			if (colorDistance(currentPixel, color) &lt; distance)
			{
				simg[i,j] = currentPixel;
			}
			else
			{
				auto I = cast(int) currentPixel.luminance;
				simg[i,j] = new RGBColor(I, I, I);
			}
		}
	}
 
	return simg;
	
}

void main()
{
	auto v = new P6Image;
	with (v)
	{ 
		 load(`Lenna.ppm`);
		 excludeAnotherColor(v, new RGBColor(128, 0, 200), 130).save(`discoloration.ppm`);
	}
}

Результат:

И самое забавное: большая часть библиотеки писалась на моем смартфоне ZTE Blade A5 2019 (16 Gb) с использованием Termux (компилятор ldc, а в качестве текстового редактора и моего личного «отладчика форматов» был применен micro)  и файлового менеджера с просмотрщиком PortableAnyMap форматов FileViewer for Android (от Sharpened Production) ! И да, в отличие от dlib, ppmformats работает на смартфоне !

P.S: Первое видео от нас, просим строго не судить, поскольку записывалось «на коленке» (т.е прямо со смартфона):

P.P.S: Автор настоятельно рекомендует использовать ppmformats с GitHub именно в виде файла исходного текста, поскольку пока еще не удалось оформить исходники в библиотеку (есть проблемы с импортом, кто может помочь с решением просьба отписаться или в комментариях или в нашей группе ВКонтакте. Ссылки есть в блоге)

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