Архив проекта: 6_fractals.py
На этом занятии
мы с вами сделаем заметное улучшение L-системы, созданной на предыдущих
занятиях – добавим в нее параметры. О чем здесь речь и что это за параметры? Смотрите,
когда мы с вами рассматривали ветвления и строили фрактальное дерево с
аксиомой:
и правилами:
l_sys.add_rules(("F", "FF"), ("A", "F[+A][-A]"))
Ветки на каждой
новой итерации увеличивались в два раза. И это было много. Было бы правильнее
здесь использовать множитель, например, 1,5, то есть, рост был бы в полтора
раза, а не в два. Текущей L-системой делать такие манипуляции
проблематично. И вот здесь нам на помощь приходят параметры. Было бы хорошо
использовать значение, указывающее длину линии рисования. Например, команда:
F(1)
означала бы
рисование линии заданной длины (то есть, без изменений), а команда:
F(1.5)
рисование линии
полуторной длины. И так далее. То есть, указывая после команды в круглых
скобках параметр (или несколько параметров), можно существенно улучшить работу L-системы и
разнообразить генерируемые фрактальные кривые.
Например, для
рисования нашего дерева, можно было бы записать правила в виде:
A
→ F(1)[+A][-A]
F(x) → F(1.5x)
Давайте добавим
такой функционал в наш класс. И чтобы сильно не усложнять процесс, я сделаю
несколько очевидных ограничений:
- параметры могут
быть только числами (целыми или вещественными);
- обобщенные
параметры внутри круглых скобок обозначаются малыми латинскими символами.
В большинстве
случаев эти ограничения могут быть легко выполнены.
Реализация,
которую я приведу, скорее, соответствует учебному примеру, чем реальному
проекту. С точки зрения скорости выполнения программу, конечно же можно
оптимизировать. Но я решил в ущерб скорости сохранить ясность написания кода и
существенно не переделывать программу, написанную на предыдущих занятиях.
Как и прежде,
будем полагать, что команды записываются в строке и если у них имеются
параметры, то они записываются в круглых скобках через запятую:
"F(1)+F(2)+F(3)"
В данном примере
первый параметр увеличивается на 1 на каждой итерации. Давайте вначале
реализуем такой функционал в нашей L-системе. Для этого пропишем
аксиому вот в таком виде:
а правило
изменения параметра я опишу с использованием лямбда-функции, следующим образом:
l_sys.add_rules(("A(x)", lambda x: f"F({x+1})+A({x+1})"))
Смотрите, мы
здесь записали команду A(x) в обобщенном
виде, где x – это любое
число (целое или вещественное), которое может быть записано в круглых скобках
сразу после заглавной буквы A. Соответственно, лямбда-функция тоже
имеет один параметр (я его обозначил через x, хотя можно
определить и любым другим именем). Далее, через двоеточие указываем, что должна
возвращать эта функция, когда встретится команда A с параметром. В
данном случае, будет возвращена строка со значением параметра x+1. Таким образом,
на каждой итерации мы будем получать новую команду F с увеличенным
на единицу предыдущим значением.
Теперь нам нужно
«научить» L-систему
понимать такие параметаризированные команды. Для этого я воспользуюсь модулем
для обработки
строк с помощью регулярных выражений. Если вы не знаете что это такое и как им
пользоваться, то на этом канале есть курс по регулярным выражениям:
https://www.youtube.com/watch?v=1SWGdyVwN3E&list=PLA0M1Bcd0w8w8gtWzf9YkfAxFCgDb09pA
В частности, я
сделаю следующее. Команды у правил, записанные в виде:
F(x)
F(x, y) AX(a, b) XY(c, d, e)
преобразуем к
виду регулярного шаблона:
F(x) → F\(([-+]?\b\d+(?:\.\d+)?\b)\)
F(x, y) → F\(([-+]?\b\d+(?:\.\d+)?\b), ([-+]?\b\d+(?:\.\d+)?\b)\)
Здесь фрагмент
([-+]?\b\d+(?:\.\d+)?\b)
позволяет
выделять из строки любое целое или вещетсвенное число. Поэтому преобразованные
ключи правил можно применять для поиска соответствующих параметаризированных
команд.
Именно это мы
сделаем в методе add_rules() класса LSystem2D:
def add_rules(self, *rules):
for key, value in rules:
key_re = "" # шаблон регулярного выражения для выделения ключа
if not isinstance(value, str): # ключ с параметрами
key_re = key.replace("(", r"\(")
key_re = key_re.replace(")", r"\)")
key_re = re.sub(r"([a-z]+)([, ]*)", lambda m: r"([-+]?\b\d+(?:\.\d+)?\b)" + m.group(2), key_re)
self.key_re_list.append(key_re)
self.rules[key] = (value, key_re)
Здесь вначале
идет проверка, если значение не является строкой, значит, это функция, а ключ
имеет один или несколько параметров. Далее, мы преобразовываем ключ в шаблон
для выделения соответствующей параметаризованной команды и добавляем его в
список шаблонов, который нам понадобится позже. Затем, в словаре rules по исходному
ключу key сохраняем
кортеж со значением ключа и его шаблоном (если команда без параметров, то
шаблон будет пустой строкой). В результате, метод add_rules() будет
работать как с обычными командами, так и с параметаризированными. Разумеется,
свойство key_re_list нужно прописать при создании экземпляра L-системы:
self.key_re_list = [] # список шаблонов команд
Следующим шагом перепишем
метод generate_path() с учетом
параметров:
def generate_path(self, n_iter):
for n in range(n_iter):
for key, values in self.rules.items():
if isinstance(values[0], str):
self.state = self.state.replace(key, values[0].lower())
else: # команда с параметром
self.function_key = values[0] # ссылка на лямбда-функцию
self.state = re.sub(values[1], self.update_param_cmd, self.state)
self.function_key = None
self.state = self.state.upper()
Здесь мы
смотрим, если команда без параметров, то делаем прежнюю замену, а для
параметаризованных команд делаем замену с вызовом лямбда-функции. Для этого
используется дополнительная функция update_param_cmd(), которая
вначале формирует список параметров в виде вещественных чисел, а затем,
вызывает лямбда-функцию с этими параметрами:
def update_param_cmd(self, m):
if not self.function_key:
return ""
args = list(map(float, m.groups()))
return self.function_key(*args).lower()
Мы здесь
предполагаем, что лямбда-функции всегда возвращают строку, поэтому в конце
стоит вызов метода lower() – перевод в нижний регистр. Кроме того, пропишем свойство
function_key
в конструкторе класса:
Как вы уже
догадались, через него мы вызываем нужную лямбда-функцию.
Давайте
посмотрим, что у нас получилось. На данный момент L-система должна
уметь обрабатывать параметры и формировать параметаризованный маршрут для
черепашки. Учитывая нашу аксиому и правило, после первой итерации получим набор
команд в виде строки:
"F(1)+F(2.0)+A(2.0)"
Отлично, то что
нужно. Увеличим число итераций до четырех и в консоли появится уже следующая
строка:
"F(1)+F(2.0)+F(3.0)+F(4.0)+F(5.0)+A(5.0)"
Все как надо.
Причем, наша L-система может
обрабатывать произвольное число параметров у команд. Например, можно задать
аксиому в виде:
axiom = "F(1, 2)+A(1, 2)"
и правило для ее
преобразования:
l_sys.add_rules(("A(a, b)", lambda x, y: f"F({x+1}, {1.5*y})+A({x+1}, {1.5*y})"))
Я здесь
намеренно параметры указал через символы a и b, а у
лямбда-функции – через x, y, показывая тем
самым, что имена параметров у конмад и функции могут быть разными, главное их
последовательность.
После четырех
итераций мы увидим строку:
"F(1,
2)+F(2.0, 3.0)+F(3.0, 4.5)+F(4.0, 6.75)+F(5.0, 10.125)+A(5.0, 10.125)"
Здорово, да!? Осталось
научить черепашку исполнять эти параметаризованные инструкции. Для этого мы
перейдем в метод draw_turtle() и первым делом сформируем список шаблонов для
выделения команд из строки:
key_list_re = "|".join(self.key_re_list)
Зачем нужен этот
шаблон? Сейчас увидите. Ниже вместо цикла по отдельным символам, мы будем
делать цикл по отдельным командам:
for values in re.finditer(r"(" + key_list_re + r"|.)", self.state):
Этим мне
нравится язык Python – здесь все
делается относительно просто и красиво. Метод finditer() модуля re возвращает
итератор для перебора команд из строки self.state и текущая
команда попадает в переменную values. Но values – это объект Match, а не строка,
поэтому нам в цикле нужно взять выделенный фрагмент строки с помощью метода group():
А дальше сделаем
так. Если символ F содержится в выделенной команде, то мы пытаемся
сформировать список аргументов. При наличии параметров вызываем метод черепашки
forward, умножая на
значение первого аргумента:
if 'F' in cmd:
args = [float(x) for x in values.groups()[1:] if x]
if len(args) > 0:
self.t.forward(self.length*args[0])
else:
self.t.forward(self.length)
Иначе, выполняем
прежнюю команду. Остальные команды пока оставим в прежнем виде.
У вас здесь
может возникнуть вопрос. Почему именно первый параметр команды F умножается на
длину, то есть, отвечает за длину маршрута рисования? Да, в общем то, не
почему. Просто я так решил. Пусть первый параметр у F отвечает за
длину. Смыслом параметры мы наполняем сами – как решим, так черепашка и будет их
обрабатывать. В данном случае, первый параметр – это длина линии. В ваших
параметрических L-системах это может быть по другому.
Итак, давайте
теперь с помощью этой системы нарисуем фрактальное дерево. В качестве начальной
аксиомы выберем строку:
а правила
пропишем, следующим образом:
l_sys.add_rules(("A", "F(1)[+A][-A]"), ("F(x)", lambda x: f"F({1.5*x})"))
l_sys.generate_path(5)
После выполнения
увидим следующее изображение:
На мой взгляд,
дерево получилось естественнее, чем на предыдущем занятии. Здесь соотношение
размера свола и веток ближе к реальным деревьям. И все благодаря использованию
параметра команды F.
В заключение
этого занятия я сделаю небольшое улучшение программы. В частности, вынесу из
класса LSystem2D логику обработки параметров, так как для разных фракталов она
может существенно меняться и прописывать ее жестко в классе L-системы это,
очевидно, неправильный подход.
Вначале, в
конструкторе класса я добавлю словарь, связывающий команду с функцией, которую
следует выполнить:
self.cmd_functions = {} # словарь связей параметаризованных команд и функций
И пропишу еще
один метод, который будет добавлять функции к соответствующей команде:
def add_rules_move(self, *moves):
for key, func in moves:
self.cmd_functions[key] = func
Как пользоваться
этой функцией? Например, в нашем случае можно связать команду F с
пользовательской функцией, следующим образом:
l_sys.add_rules_move(("F", cmd_turtle_fd))
Мы передаем
кортеж, первый элемент – это команда черепашки, а второй – ссылка на функцию,
которую следует выполнить для параметаризованной команды. Саму же функцию мы
определяем отдельно от класса L-системы, например, так:
# список функций для управления параметаризированными командами
# у всех функций будет префикс cmd_ и первый параметр t - черепашка
def cmd_turtle_fd(t, length, *args):
t.fd(length*args[0])
А в методе draw_turtle()
в цикле пропишем следующее:
def draw_turtle(self, start_pos, start_angle):
# ***************
turtle.tracer(1, 0) # форсажный режим для черепашки
self.t.up() # черепашка воспаряет над поверхностью (чтобы не было следа)
self.t.setpos(start_pos) # начальная стартовая позиция
self.t.seth(start_angle) # начальный угол поворота
self.t.down() # черепашка опускается на "грешную землю"
turtle_stack = []
key_list_re = "|".join(self.key_re_list)
# ***************
# for move in self.state:
for values in re.finditer(r"(" + key_list_re + r"|.)", self.state):
cmd = values.group(0)
args = [float(x) for x in values.groups()[1:] if x]
if 'F' in cmd:
if len(args) > 0 and self.cmd_functions.get('F'):
self.cmd_functions['F'](t, self.length, *args)
else:
self.t.fd(self.length)
elif 'S' in cmd:
if len(args) > 0 and self.cmd_functions.get('S'):
self.cmd_functions['S'](t, self.length, *args)
else:
self.t.up()
self.t.forward(self.length)
self.t.down()
elif '+' in cmd:
if len(args) > 0 and self.cmd_functions.get('+'):
self.cmd_functions['+'](t, self.angle, *args)
else:
self.t.left(self.angle)
elif '-' in cmd:
if len(args) > 0 and self.cmd_functions.get('-'):
self.cmd_functions['-'](t, self.angle, *args)
else:
self.t.right(self.angle)
elif "[" in cmd:
turtle_stack.append((self.t.xcor(), self.t.ycor(), self.t.heading(), self.t.pensize()))
elif "]" in cmd:
xcor, ycor, head, w = turtle_stack.pop()
self.set_turtle((xcor, ycor, head))
self.width = w
self.t.pensize(self.width)
turtle.done() # чтобы окно не закрывалось после отрисовки
То есть, мы
здесь при обнаружении параметров у команды, пытаемся вызвать связанную с ней
пользовательскую функцию. Если же она не находится или команда без параметров,
то отрабатывается стандартное поведение черепашки.
Теперь у нас
обработка параметров и базовая реализация L-системы
разделены между собой.
Давайте для еще
одного примера добавим второй параметр у команды F, отвечающий за
толщину линии рисования:
F(<длина линии>,
<толщина линии>)
и параметр у
команд поворота налево и направо, влияющий на изменение угла поворота:
+(<множитель угла
поворота>)
Для этого
аксиому оставим прежней, а набор правил и функций, будут следующими:
l_sys.add_rules(("A", "F(1, 1)[+(1)A][-(1)A]"),
("F(x, y)", lambda x, y: f"F({1.5*x}, {1.7*y})"),
("+(x)", lambda x: f"+({1.1*x})"),
("-(x)", lambda x: f"-({1.1*x})"),
)
l_sys.add_rules_move(("F", cmd_turtle_fd), ("+", cmd_turtle_left), ("-", cmd_turtle_right))
А функции
запишем в виде:
def cmd_turtle_fd(t, length, *args):
t.pensize(args[1])
t.fd(length*args[0])
def cmd_turtle_left(t, angle, *args):
t.left(angle * args[0])
def cmd_turtle_right(t, angle, *args):
t.right(angle * args[0])
Однако, сейчас,
при запуске программы мы увидим ошибку, так как регулярные выражения ожидают в
качестве команд символы латинского алфавита. А символы + и - нужно дполнительно
экранировать в регулярных выражениях. Для этого в методе add_rules() пропишем дву
строчки:
key_re = key_re.replace("+", r"\+")
key_re = key_re.replace("-", r"\-")
После запуска
увидим следующее изображение фрактального дерева:
Наш фрактал стал
еще ближе к реальным природным объектам.
На этом мы
завершин наше объемное занятие. Весь код будет выложен на гитхаб, где вы
сможете подробно его изучить.