Сегодня я расскажу о том, как я осуществил перенос наработок из проекта нашей библиотеки для обработки изображений Raster and Image Processor (RIP) в библиотеку «швейцарский нож для D» dlib. В статье я немного расскажу о том, как при минимуме усилий и использованной инфраструктуре проекта rip, мне удалось подарить вторую жизнь старой идее реализации Итерируемой Системы Функций (ИФС) и перенести ее со старой dgui (об этом я уже как-то писал) в новую среду.
Итерируемая система функций (более известна под названием итерированная система функций) или ИФС – это удобный математический аппарат для описания фрактальных или фракталоподобных изображений через задание системы уравнений. Система уравнений задается для некоторой начальной точки (обычно, эта точка с координатами (0;0), т.е начало координат) и применяется к ней итеративно, причем, каждое уравнение из системы имеет свою вероятность «быть примененным» к точке. Исходя из этого, для получения некоторого изображения из единственной точки нам нужна система уравнений и некоторое количество раз применения для уравнений, каждое из которых применяется единственный раз в некоторый момент времени. Следуя традиции, один раз применения системы к точке (т.е применения какого-либо уравнения из системы) называется одним поколением ИФС.
Также, для удобства позиционирования графического образа системы на плоскость, можно ввести еще ряд параметров, а именно, масштабы по осям X и Y, а также коэффициенты смещения относительно стартовой точки по тем же самым осям.
Применяя все эти допущения, математический аппарат не содержит ничего серьезного, кроме простой подстановки текущих значений X и Y, в простые линейные уравнения, содержащие шесть коэффициентов. Эти коэффициенты удобно обозначить латинскими буквами: a, b, c, d, e, f и ввести еще один коэффициент p, обозначающий вероятность использования уравнения на текущем шаге. Детали реализации вычисления по этим коэффициентам были нами рассмотрены в статье «Усовершенствуем лист папоротника», но в контексте библиотеки dgui.
Теперь, когда обозначены многие аспекты относительно предыдущей реализации ИФС, можно перейти к непосредственному переносу разработок из rip в dlib.
В rip были использованы несколько иные соображения, чем в dlib. Дело в том, что при создании rip, мы предполагали, что в пределах этой библиотеки можно будет использовать в функциях любые числовые типы (или приводимые к таковым), что позволило бы снизить нагрузку на программиста и сделало бы использование библиотеки более комфортным. Этого мы добились тем, что ввели автоматическую проверку на то, все ли из используемых в шаблонах параметров типов, являются числовыми, а также автоматическим приведением к некоторому удобному для расчетов арифметическому типу.
И этот факт нужно учесть, взяв из модуля templates библиотеки rip шаблон allArithmetic.
Помимо этого, в rip мы ввели еще один интересный шаблон addTypedGetter, который позволял нам автоматически генерировать свойства многих объектов библиотеки, которые могли бы давать конкретный нужный в данный момент тип с помощью прямого его указания после имени свойства.
Эти шаблоны одного из базовых модулей rip мы разместим в файле concepts.d:
module concepts;
private
{
import std.meta : allSatisfy;
import std.traits : isIntegral, isFloatingPoint, Unqual;
}
// Проверяем все ли типы арифметические (т.е числовые)
template allArithmetic(T...)
if (T.length >= 1)
{
template isNumberType(T)
{
enum bool isNumberType = isIntegral!(Unqual!T) || isFloatingPoint!(Unqual!T);
}
enum bool allArithmetic = allSatisfy!(isNumberType, T);
}
// Генерируем универсальный геттер
template addTypedGetter(string propertyVariableName, string propertyName)
{
import std.string : format;
const char[] addTypedGetter = format(
`
@property
{
T %2$s(T)() const
{
import std.conv : to;
alias typeof(return) returnType;
return to!(returnType)(%1$s);
}
}`,
propertyVariableName,
propertyName
);
}
В этом коде, используется наш привычный прием с кодогенерацией на стадии компиляции, но кроме этого мы реализуем генерацию геттера с требуемым типом с помощью приведения к «универсальному типу» возвращаемого значения (эта конструкция неплохо описана в руководстве по D).
А теперь можно перейти к реализации непосредственно ИФС для чего создадим файл ifs.d и добавим туда очень много импортов из стандартной библиотеки Phobos (и из модуля concepts), причем в приватном режиме:
module ifs;
private
{
import std.algorithm;
import std.math;
import std.range;
import std.random;
import std.string;
import concepts;
import dlib.image;
import std.stdio;
}
Создадим структуру данных, которая будет описывать стартовую точку для отрисовки итерируемой системы функций:
// Стартовая точка для IFS
class IFS_StartPoint
{
private
{
float x;
float y;
}
// Универсальный конструктор
this(T, U)(T x, U y)
if (allArithmetic!(T, U))
{
this.x = cast(float) x;
this.y = cast(float) y;
}
// Получить X координату
mixin(addTypedGetter!("x", "getX"));
// Получить Y координату
mixin(addTypedGetter!("y", "getY"));
// Установить X координату
void setX(T)(T x)
if (allArithmetic!T)
{
this.x = cast(float) x;
}
// Установить Y координату
void setY(T)(T y)
if (allArithmetic!T)
{
this.y = cast(float) y;
}
}
В данном случае, мы создаем класс, отражающий наше понимание некоторой точки плоскости: в этом классе есть универсальный конструктор, способный принять любой числовой тип данных, набор сеттеров для X и Y координат точки, а также микшины, которые встраивают «универсальный геттер» (который взят из модуля concepts).
Аналогично этому классу создаем класс, который будет представлять собой набор масштабирующих (управляют масштабом ИФС по осям) и смещающих (управляют смещением ИФС относительно стартовой точки):
// Масштабирование ИФС
class IFS_Scales
{
private
{
float scaleX;
float scaleY;
float offsetX;
float offsetY;
}
this(T, U, V, W)(T scaleX, U scaleY, V offsetX, W offsetY)
if (allArithmetic!(T, U, V, W))
{
this.scaleX = scaleX;
this.scaleY = scaleY;
this.offsetX = offsetX;
this.offsetY = offsetY;
}
// Масштаб по оси X
mixin(addTypedGetter!("scaleX", "getScaleX"));
// Масштаб по оси Y
mixin(addTypedGetter!("scaleY", "getScaleY"));
// Смещение по оси X
mixin(addTypedGetter!("offsetX", "getOffsetX"));
// Смещение по оси Y
mixin(addTypedGetter!("offsetY", "getOffsetY"));
// Установить масштаб для оси X
void setScaleX(T)(T scaleX)
if (allArithmetic!T)
{
this.scaleX = cast(float) scaleX;
}
// Установить масштаб для оси Y
void setScaleY(T)(T scaleY)
if (allArithmetic!T)
{
this.scaleY = cast(float) scaleY;
}
// Установить смещение по оси X
void setOffsetX(T)(T offsetX)
if (allArithmetic!T)
{
this.offsetX = cast(float) offsetX;
}
// Установить смещение по оси Y
void setOffsetY(T)(T offsetY)
if (allArithmetic!T)
{
this.offsetY = cast(float) offsetY;
}
}
И, теперь мы можем описать одно уравнение системы итерируемых функций со всеми коэффициентами и удобным методом отображения:
// Одно уравнение ИФС
class IFS_Equation
{
private
{
float a;
float b;
float c;
float d;
float e;
float f;
float p;
}
// Получение коэффициентов ИФС
mixin(addTypedGetter!("a", "getA"));
mixin(addTypedGetter!("b", "getB"));
mixin(addTypedGetter!("c", "getC"));
mixin(addTypedGetter!("d", "getD"));
mixin(addTypedGetter!("e", "getE"));
mixin(addTypedGetter!("f", "getF"));
mixin(addTypedGetter!("p", "getP"));
// Универсальный конструктор строк для задания коэффициентов ИФС
this(R, S, T, U, V, W, Z)(R a, S b, T c, U d, V e, W f, Z p)
if (allArithmetic!(R, S, T, U, V, W, Z))
{
this.a = a;
this.b = b;
this.c = c;
this.d = d;
this.e = e;
this.f = f;
this.p = p;
}
void setA(T)(T a)
if (allArithmetic!T)
{
this.a = cast(float) x;
}
void setB(T)(T b)
if (allArithmetic!T)
{
this.b = cast(float) b;
}
void setC(T)(T c)
if (allArithmetic!T)
{
this.c = cast(float) c;
}
void setD(T)(T d)
if (allArithmetic!T)
{
this.d = cast(float) d;
}
void setE(T)(T e)
if (allArithmetic!T)
{
this.e = cast(float) e;
}
void setF(T)(T f)
if (allArithmetic!T)
{
this.f = cast(float) f;
}
// Установить вероятность применения уравнения
void setP(T)(T p)
if (allArithmetic!T)
{
this.p = cast(float) p;
}
override string toString()
{
return format("IFS_Equation(%f, %f, %f, %f, %f, %f, probability = %f)", a, b, c, d, e, f, p);
}
}
Здесь мы используем все те же приемы, что и рассмотренные ранее, в том числе и ограничение сигнатуры методов-сеттеров с помощью соответствующего шаблона (template constrain, так кажется называется эта идея). Также, сам класс берет на себя обязанность по приведению любых параметров к удобному для расчетов float.
Имея одно уравнение ИФС, которое просто служит хранилищем параметров, можно удобным образом построить контейнер, который будет хранить все уравнения некоторой системы и для этого введем псевдоним для массива уравнений системы и создадим новый класс, который будет управлять расчетами над текущей системой уравнений:
// Система уравнений ИФС
alias IFS_EquationSystem = IFS_Equation[];
// Сама ИФС
class IFS
{
private
{
SuperImage surface;
Color4f color;
IFS_EquationSystem equationSystem;
IFS_StartPoint point;
IFS_Scales scales;
ulong numberOfGeneration;
float[] probabilities;
}
this(W)(SuperImage surface, Color4f color, IFS_EquationSystem equationSystem, IFS_StartPoint point, IFS_Scales scales, W numberOfGeneration)
if (allArithmetic!(W))
{
this.surface = surface;
this.color = color;
this.equationSystem = equationSystem;
this.numberOfGeneration = cast(ulong) numberOfGeneration;
this.point = point;
this.scales = scales;
this.probabilities = equationSystem.map!(a => a.getP!float).array;
}
IFS_EquationSystem execute()
{
float x = point.getX!float;
float y = point.getY!float;
for (ulong i = 0; i < numberOfGeneration; i++)
{
auto d = dice(probabilities);
IFS_Equation eq = equationSystem[d];
auto mul0 = eq.getA!float * x + eq.getB!float * y;
auto mul1 = eq.getC!float * x + eq.getD!float * y;
x = mul0 + eq.getE!float;
y = mul1 + eq.getF!float;
// surface.drawPoint(
// color,
// scales.getOffsetX!float + scales.getScaleX!float * x,
// scales.getOffsetY!float + scales.getScaleY!float * y
// );
surface[
cast(int) (scales.getOffsetX!float + scales.getScaleX!float * x),
cast(int) (scales.getOffsetY!float + scales.getScaleY!float * y)
] = color;
}
return equationSystem;
}
}
Конструктор класса IFS примет в себя некоторую двумерную поверхность (ее роль будет играть обобщенный класс изображения SuperImage из dlib), цвет (которым и будет отрисована сама система), набор уравнений ИФС, стартовую точку и количество поколений (оно должно быть достаточно большим, чтобы пронаблюдать все интересное). Далее, метод execute описывает собственно говоря саму процедуру итерации через поколения ИФС и на основе расчетов окрашивает нужным цветом рассчитанные с помощью уравнений точки.
В самой процедуре расчета только один момент реально заслуживает отдельного упоминания - это процедура выбора уравнения на основе вероятности, которая осуществляется в одну строку с помощью интересной функции из std.random под названием dice. Эта функция принимая набор вероятностей генерирует число от 0 до n, где n - это количество вероятностей в наборе. Сгенерированное с помощью dice значение используется как индекс для выбора уравнения, а сами вероятности берутся из переменной probabilities класса IFS, которое в свою очередь получается с помощью пробега по массиву уравнений с помощью map и последующей трансформацией диапазона в обычный массив из float`ов.
Прежде чем перейти к испытаниям, я предлагаю вам ненадолго вернуться к статье «Усовершенствуем лист папоротника» и освежить в памяти одну деталь: дело в том, что я уже описывал откуда я брал коэффициенты для уравнений... Вся суть в том, что существует много ресурсов, посвященных фрактальным изображениям и на некоторых из них описания фракталов даются в форме понятного и легкочитаемого формата, использованного когда-то в старой программе FRACTINT.
Именно, использование формата этой программки, а также некоторое его описание в упомянутой статье, поможет нам облегчить испытание универсального модуля для построения ИФС, а парсер этого формата легко написать в несколько строк, используя функциональный подход:
// from FRACTINT format to standart IFS equations
IFS_EquationSystem fromFRACTINT(string description)
{
import std.algorithm;
import std.conv : to;
import std.range;
import std.string;
IFS_EquationSystem ifs_system;
auto startDescription = description.indexOf("{");
auto endDescription = description.indexOf("}");
IFS_Equation toEquation(float[] equation)
{
return new IFS_Equation(
equation[0],
equation[1],
equation[2],
equation[3],
equation[4],
equation[5],
equation[6],
);
}
description[startDescription+1..endDescription]
.split
.map!(a => to!float(strip(a)))
.chunks(7)
.map!(a => toEquation(a.array))
.each!(a => ifs_system ~= a);
return ifs_system;
}
В этом коде, мы используем тот факт, что описание во FRACTINT-формате начинается с имени ИФС, после которого следуют фигурные скобки, внутри которых и заключены интересующие нас коэффициенты. Эти коэффициенты расположены внутри описания построчно, т.е одной строке соответствует одно уравнение с шестью коэффициентами и одним параметром вероятности применения уравнения. Таким образом с помощью indexOf мы определяем позиции скобок и с помощью них отделяем от строки описание, оставляя только голую строку с числами. После этого, разбиваем строку по пробелам, формируя таким образом, список из чистых значений, но в строковой форме. Данную форму мы преобразуем с помощью шаблона to и алгоритма map в диапазон элементов типа float (на всякий случай убираем все лишнее с помощью strip). Теперь, помня про то, что каждое уравнение - это ровно семь значений, разбиваем диапазон на блоки по семь элементов в каждом и проходим по блокам, преобразуя каждый из них с помощью уже подготовленной ранее функции toEquation в уравнение. Заключительным этапом работы парсера является накапливание всех уравнений с помощью алгоритма each в единый массив, описывающий систему уравнений ИФС.
Испытаем созданный код в проекте dub с добавленной в качестве зависимости библиотеки dlib, используя следующий код:
import std.stdio;
import dlib.image;
import ifs;
void main()
{
// Поверхность
auto surface = image(8000, 8000);
// Система уравнений в удобном формате
string description = "
Fern {
0.00 0.00 0.00 0.16 0.00 0.00 0.01
0.85 0.04 -0.04 0.85 0.00 1.60 0.85
0.20 -0.26 0.23 0.22 0.00 1.60 0.07
-0.15 0.28 0.26 0.24 0.00 0.44 0.07
}
";
// Готовая система уравнений
IFS_EquationSystem equations = fromFRACTINT(description);
// Задание цвета
Color4f color = Color4f(0.0f, 120 / 255.0f, 200 / 255.0f);
// Начальная точка
IFS_StartPoint startPoint = new IFS_StartPoint(0 , 0);
// Масштабирующие и смещающие коэффициенты
IFS_Scales scales = new IFS_Scales(2500, 700, 0.00001, 0.00001);
// Итерируемая система функций
IFS ifs = new IFS(surface, color, equations, startPoint, scales, 100_000_000);
// Запускаем генерацию IFS
ifs.execute;
// Сохраняем изображение
surface.savePNG("ifs_draw.png");
}
Результатом испытания будет нечто с потрясающей степенью детализации:

Красиво, а? И теперь, вы тоже так можете.
P.S: Имейте в виду, что вы не найдете этих блоков в текущей версии rip из репозитория dub, поскольку эти наработки не были включены в текущую ветвь. Кроме того, функция fromFRACTINT планировалась как скрытая функция внутри модуля, доступная тем, кто не поленился покопаться в исходном коде библиотеки (оставлена как "пасхальное яйцо" для демонстрации возможностей).
Описание ИФС в виде формата FRACTINT вы можете найти здесь