Добавление графических примитивов в dlib

Недавно один из авторов блога опубликовал материал по работе с такой замечательной вещью, как dlib, в которой есть много замечательных функций и шаблонов. Однако, даже поработав с ней несколько раз в реальном проекте, все равно чувствуешь, что в ней чего-то не хватает для нормальной работы таких помешанных на графике и математике, как я.

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

Возникла гениальная мысль: а почему бы не сделать некоторые примитивы заранее, а потом просто взять и выложить их в открытых доступ, чтобы и остальные не мучились ?

Так собственно и появилась моя небольшая задумка — небольшой фонд функций, рисующих всякую всячину из геометрических образов самыми тривиальными способами.

Создаем новый проект с помощью dub (dub init <имя проекта>, если кто-то забыл), после чего в файл dub.json в секцию «dependencies» добавляем зависимость «dlib« : «~master«  (всегда в этих случаях выбираю master-ветвь, с ней меньше геморроя).

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

import dlib.image;

void main()
{
	auto simg = image(512, 512);

	for (int i = 0; i < 1000; i++)
	{
		for (int j = 0; j < 1000; j++)
		{
			simg[i, j] = Color4f(1.0f, 1.0f, 1.0f);
		}
	}
        simg.savePNG("sample.png");
}

Немного объясню, что тут происходит: для работы с изображением в dlib существует такой объект как SuperImage, обладающий кучей полезных методов и реализованный так, будто бы это массив (но вот подколка: авторы dlib очень талантливые люди и они смогли перегрузить метод индексации массива, так что он отличен от традиционного синтаксиса двумерных массивов D, однако, такой синтаксис более удобен и привычен многим программистам, в особенности тем, которые раньше работали под .NET). В действительности, это довольно таки сложный класс, хранящий в себе множество объектов, описывающих отдельные пиксели изображения и присвоение какому-то элементу структуры Color4f (описывающей цвет в формате RGB, только с тем отличием, что компоненты цвета задаются не целым числом от 0 до 255, а задаются с использованием долей единицы. Т.е 0.0f — для всех компонентов цвета соответствует черному фону, 1.0f для всех компонентов цвета — белому) приводит к тому, что пиксель, координаты на изображении которого соответствуют индексам в двумерном массиве SuperImage, окрашивается в нужный цвет.

В нашем случае, сначала создается объект simg (имеет тип SuperImage) и в его конструкторе задается длина и ширина изображения (в пикселах), что приводит к получению простой картинки размерностью 512 * 512, которая сразу же сохраняется в файл с помощью метода savePNG.

Итак, в принципе, теперь легко можно генерировать картинки, рисуя их попиксельно, однако, это не очень удобно (ну кроме случаев с рисованием разного рода графиков функций) и к тому же есть один нюанс: если в DFL/DGui и прочих библиотеках графического интерфейса, начало координат находится в левом верхнем углу, то в dlib начало координат находится в левом нижнем углу (!), поскольку практически все форматы изображений используют такой подход — так, что не забывает про этот несколько неприятный факт.

Для рисования разных сложных фигур задание цвета пикселю является очень неудобным приемом, в связи с тем, что нужны примитивы гораздо более высокого уровня — линии и отдельные фигуры.

Проще всего реализовать рисование линии между двумя произвольными точками, в чем легко может помочь старый как мир алгоритм цифрового дифференциального анализатора (по английски: Digital Differential Analyzer или сокращенно DDA), который в целых числах вычисляет новые точки линии, используя простое линейное приращение по координатам:

// рисование линии методом DDA
void drawDDALine(ref SuperImage simg, Color4f color, int x1, int y1, int x2, int y2)
{
	import std.algorithm : max;
	import std.math : abs;
	
	int deltaX = abs(x1 - x2);
	int deltaY = abs(y1 - y2);
	int length = max(deltaX, deltaY);
	
	if (length == 0)
	{
		simg[x1, y1] = color;
	}
	
	float dx = cast(float) (x2 - x1) / cast(float) length;
	float dy = cast(float) (y2 - y1) / cast(float) length;
	float x = x1;
	float y = y1;
	
	length++;
	while(length--)
	{
		x += dx;
		y += dy;
		simg[cast(int) x, cast(int) y] = color;
	}
	
}

Сам алгоритм , в данном случае, реализован достаточно грубо и не является оптимизированным, но несмотря на это, он очень удобен и практичен (более подробно об алгоритме вы можете прочитать тут и соответственно, сможете увидеть, что изложенная в этой статье версия алгоритма является буквальным портом листинга на С++ с этого сайта на D).

Функция отрисовки линии принимает несколько параметров: simg — изображение, на которое будет выводиться линия (объявлен с параметром ref для того, чтобы изменять непосредственно сам объект); color — цвет линии в формате RGB с описанием компонентов в долях единицы (почти в процентах) и координаты (x1, y1, x2, y2) точек, которые будут соединены линией. Также, система координат та же самая, что используется в dlib, без всяких изменений.

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

Для испытаний алгоритма отрисовки линии по Брезенхэму был использован следующий код:

// рисование линии по алгоритму Брезенхэма
void drawBresenhamLine(ref SuperImage simg, Color4f color, int x1, int y1, int x2, int y2)
{
	import std.algorithm : max;
	import std.math : abs;

	int dx = (x2 - x1 >= 0 ? 1 : -1);
	int dy = (y2 - y1 >= 0 ? 1 : -1);

	int lengthX = abs(x2 - x1);
	int lengthY= abs(y2 - y1);
	int length = max(lengthX, lengthY);

	if (length == 0)
	{
		simg[x1, y1] = color;
	}

	if (lengthY <= lengthX)
	{
		int x = x1;
		float y = y1;

		length++;
		while (length--)
		{
			simg[x, cast(int) y] = color;
			x += dx;
			y += dy * (cast(float) (lengthY)) / lengthX;
		}
	}
	else
	{
		float x = x1;
		int y = y1;

		length++;
		while(length--)
		{
			simg[cast(int) x, y] = color;
			x += dx * (cast(float) (lengthX)) / lengthY;
			y += dy;
		}
	}

}

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

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

Для рисования окружности, можно использовать тот факт, что окружностью называется такая линия, точки которой равноудалены от некоторой точки, которую принято называть центром. Сам по себе этот факт ничего не меняет, однако, если вспомнить полярную систему координат (да, да, наверное уже надоел ей, но уж очень она интересная и местами даже очень практичная!), то уравнение окружности в полярных координатах выглядит достаточно просто: r = phi — и тогда остается только перебрать все углы от 0 до 360 с некоторым шагом и построить геометрический образ уравнения в пикселях:

// рисование окружности
void drawCircle(ref SuperImage simg, Color4f color, int x, int y, uint r)
{
	import std.math : cos, sin, PI;

	for (float i = 0.0; i < 360.0; i += 0.01)
	{
		auto X = cast(int) (x + r * cos(i * PI / 180.0));
		auto Y = cast(int) (y + r * sin(i * PI / 180.0));
		simg[X, Y] = color;
	}
}

Как видите, все очень просто и не использует никаких сторонних примитивов.

Самое интересное в том, что с примитивами можно пойти гораздо дальше: окружность, если вспомнить кое-что из геометрии (по-моему, аналитической). Окружность является представителем класса так называемых конических сечений, в ряд которых входят эллипс, парабола и гипербола, соответственно, если вспомнить одну из статей блога про полярные координаты, то можно запросто сделать обобщение для рисования этих линий, например вот так:

// рисование конических сечений
void drawConicSection(ref SuperImage simg, Color4f color, int x, int y, float l, float e)
{
	import std.math : cos, sin, PI;

	for (float i = 0.0; i < 360.0; i += 0.01)
	{
		auto r = l / (1.0 - e * cos(i * PI / 180.0));
		auto X = cast(int) (x + r * cos(i * PI / 180.0));
		auto Y = cast(int) (y + r * sin(i * PI / 180.0));
		simg[X, Y] = color;
	}
}

Параметры l и e обеспечивают необходимый уровень обобщения: l — это фокальный параметр (т.е параметр, показывающий расстояние между фокусами конического сечения), а e — это эксцентрисет (параметр, насколько я помню, характеризующий кривизну конического сечения). При этом от эксцентрисетного параметра зависит конкретный вид кривой: если e > 1, то получается гипербола; если e = 1, то получается парабола; если e < 1, то получается эллипс и в случае равенства e нулю, получается окружность с радиусом l.

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

Реализация прямоугольника — это по сути, рисование по точкам четырех прямых линий, но без использования уже описанных способов рисования линии (почему так ? потому что, хочется иметь атомарный примитив рисования, а также из-за того, что алгоритмы рисования линий не совсем точно срабатывают для случая прямоугольника) и несмотря на довольно простую идею (рисование в цикле)  пришлось столкнуться с некоторыми трудностями:

// рисование прямоугольника
void drawRectangle(ref SuperImage simg, Color4f color, int x, int y, uint w, uint h)
{
	for (uint a = 0; a < h; a++)
	{
		simg[x, y + a] = color;
	}

	for (uint b = 0; b < w; b++)
	{
		simg[x + b, y + h] = color;
	}

	for (uint c = 0; c < h; c++)
	{
		simg[x + w, y + c] = color;
	}

	
	for (uint d = 0; d < w; d++)
	{
		simg[x + d, y] = color;
	}
}

Видно, что в принципе можно было обойтись двумя циклами, однако, трудность состоит в том, что одновременная запись в массив SuperImage в двух циклах приведет к некорректному результату, в итоге, придется пожертвовать чистотой кода и записать всю процедуру в четырех независимых циклах (честное слово, я не знаю, почему нужно сделать именно так!).

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

// окружность с заливкой
void fillCircle(ref SuperImage simg, Color4f color, int x, int y, uint r)
{
	import std.math : cos, sin, PI;
	
	for (float i = 0.0; i < 360.0; i += 0.01)
	{
		for (float j = 0; j < r; j++)
		{
			auto X = cast(int) (x + j * cos(i * PI / 180.0));
			auto Y = cast(int) (y + j * sin(i * PI / 180.0));
			simg[X, Y] = color;
		}
	}
}

// рисование прямоугольника с заливкой
void fillRectangle(ref SuperImage simg, Color4f color,int x, int y, uint w, uint h)
{
	for (int i = 0; i < w; i++)
	{
		for (int j = 0; j < h; j++)
		{
			simg[x + i, y + j] = color;
		}
	}
}

И после всего этого, можно реализовать кое-что немного нетривиальное, а именно рисование правильного n-угольника. Нарисовать такую фигуру достаточно просто: необходимо по количеству сторон n-угольника рассчитать углы (360.0 / n — величина одного угла), после чего, считая угол от нулевой точки (вспоминаем тригонометрию: отсчет углов ведется против часовой стрелки) поставить точки n-угольника с учетом уже рассчитанных углов, полагая, что эти точки лежат на окружности, описанной вокруг нашего многоугольника:

void newFigure(ref SuperImage simg, Color4f color,int x, int y, uint r, int n)
{
	import std.math : cos, sin, PI;

	float part = 360.0 / n;
	auto tmpX = cast(int) (x + r * cos(0));
	auto tmpY = cast(int) (y + r * sin(0));
	simg[tmpX, tmpY] = color;

	for (float i = 0.0; i < 360.0; i += part)
	{
		auto X = cast(int) (x + r * cos(i * PI / 180.0));
		auto Y = cast(int) (y + r * sin(i * PI / 180.0));
		simg.drawDDALine(color, tmpX, tmpY, X, Y);
		tmpX = X;
		tmpY = Y;
	}

	simg.drawDDALine(color, tmpX, tmpY, x + r, y);
}

Теперь указывая положение многоугольника и количество его углов можно построить практически любой n-угольник, правильно подобрав радиус описанной вокруг него окружности (попробуйте, например, построить многоугольник, имеющий 65 536 сторон!).

Ну и напоследок, демонстрация практически всех описанных в статье графических элементов:

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