С одной строкой кода или даже меньше

Введение

Фраза «я никогда не видел ничего красивее» должна использоваться только для фракталов. Конечно, есть «Мона Лиза», «Звездная ночь» и «Рождение Венеры» (кстати, все они были разрушены искусственным интеллектом), но я не думаю, что какой-либо художник или человек мог бы что-то создать по-королевски удивительны как фракталы.

Слева у нас есть культовый фрактал, множество Мандельброта, обнаруженный в 1979 году, когда не было Python или программного обеспечения для построения графиков.

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

Но прежде чем мы сможем построить множества Мандельброта или Жюлиа (но, поверьте мне, мы это сделаем), нам предстоит многое охватить. Если вы здесь только для того, чтобы увидеть крутые картинки, я настоятельно рекомендую скачать программное обеспечение Fraqtive с открытым исходным кодом (и обалдеть!), которое я использовал для создания GIF выше и ниже:

Если вы просто хотите построить множество Мандельброта в Python с помощью одной строки кода, вот она (нет, подзаголовок не был кликбейтом):

from PIL import Image

Image.effect_mandelbrot((512, 512), (-3, -2.5, 2, 2.5), 100).show()

Но если вы хотите спуститься в красивую кроличью нору фракталов и научиться строить их и, самое главное, правильно раскрашивать, тогда читайте дальше!

В этом посте мы узнаем, как строить базовые (но все же очень крутые) множества Мандельброта, используя Matplotlib и NumPy. Затем мы выведем все на совершенно новый уровень с Pillow в будущих статьях.

Давайте начнем.

Комплексные числа в Python

Программисты Python не имеют дело с комплексными числами ежедневно. Поскольку мы будем много работать с ними в этом уроке, этот раздел послужит учебником.

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

num1 = 2 + 1j
num2 = 12.3 + 23.1j

type(num1)
complex

Если вас смущает вид мнимых чисел, представленных j вместо i (привет, математики), вы можете использовать встроенную функцию complex:

2 + 3j == complex(2, 3)
True

После создания вы можете получить доступ к действительным и мнимым компонентам комплексных чисел с атрибутами real и imag:

num1.real
2.0
num2.imag
23.1

Еще одним важным свойством комплексных чисел для целей этой статьи является их абсолютное значение. Абсолютное значение или модуль комплексного числа измеряет его расстояние от начала координат (0, 0) в комплексной плоскости. Он определяется как квадратный корень из суммы его реальной и мнимой частей (спасибо, Пифагор).

abs(1 + 3.14j)
3.295390720385065

Этого нам будет достаточно, чтобы создать несколько потрясающих вещей. Давайте начнем!

Простая формула, большой набор

Наше путешествие начинается с выяснения того, принадлежит ли некоторое комплексное число c множеству Мандельброта, что на удивление просто. Все, что нам нужно сделать, это применить приведенную ниже формулу и создать последовательность значений z:

Первый z всегда равен 0, как определено выше. Последующие элементы находятся путем возведения предыдущего z в квадрат и добавления c к результату.

Давайте реализуем процесс на Python. Мы определим функцию sequence, которая возвращает первые n элементов для данного c:

def sequence(c, n=7) -> list:
    z_list = list()
    
    z = 0
    for _ in range(n):
        z = z ** 2 + c
        z_list.append(z)
    
    return z_list

Теперь возьмем функцию на тест-драйв для набора цифр:

import pandas as pd

df = pd.DataFrame()
df['element'] = [f"z_{i}" for i in range(7)]

# Random numbers
cs = [0, 1, -1, 2, 0.25, -.1]

for c in cs:
    df[f"c={c}"] = sequence(c)
    
df

Мы видим три типа результатов: когда c равно 1 или 2, последовательность неограничена (расходится до бесконечности) по мере роста. Когда он равен -1, он перемещается туда и обратно между 0 и -1. Что касается 0,25 и -0,1, они остаются маленькими или ограниченными.

Итак, кому из этих пятерых повезло стать Мандельбротом?

Вы стабильны?

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

Теперь мы должны выяснить, сколько элементов Z нужно просмотреть, прежде чем классифицировать c как стабильный или нестабильный. Найти этот количество итераций не так-то просто, поскольку формула чувствительна даже к малейшим изменениям в c.

Но, к счастью, люди изучают множество достаточно долго, чтобы знать, что все мандельброты остаются ограниченными в радиусе двух. Это означает, что мы можем выполнить несколько десятков итераций, а числа, которые остаются относительно небольшими или меньше 2, вероятно, принадлежат множеству Мандельброта.

Итак, давайте создадим новую функцию is_stable, используя эту логику, которая возвращает True, когда число является числом Мандельброта:

def is_stable(c, n_iterations=20):
    z = 0
    
    for _ in range(n_iterations):
        z = z ** 2 + c
        
        if abs(z) > 2:
            return False
    return True

В теле этой логической функции мы устанавливаем z в 0 и запускаем алгоритм в цикле, управляемом n_iterations. На каждой итерации мы проверяем величину z, чтобы мы могли завершить цикл, если оно превысит 2 на раннем этапе, и не тратить время на выполнение остальных итераций.

Последний оператор return выполняется только в том случае, если z меньше 2 после всех итераций. Давайте проверим несколько цифр:

is_stable(1)
False
is_stable(0.2)
True
is_stable(0.26)
True
is_stable(0.26, n_iterations=30)
False

Обратите внимание, как увеличение n_iterations до 30 меняет стабильность 0,26. Как правило, значения, близкие к краю фракталов, требуют большего количества итераций для более точной классификации и создания более подробных изображений.



Как построить набор Мандельброта в Matplotlib

Этот раздел был сильно вдохновлен этой замечательной записью RealPython:



Наша конечная цель статьи — создать этого парня в Matplotib (внимание, спойлер, мы создадим что-то еще лучше!):

Изображение было создано путем окрашивания всех мандельбротов в черный цвет, а нестабильных элементов — в белый. В Matplotlib оттенки серого имеют 256 оттенков или диапазоны от 0 до 255, где 0 — полностью белый, а 255 — черный как смоль. Но вы можете нормализовать этот диапазон до 0 и 1, чтобы 0 был белым, а 1 — черным.

Эта нормализация нам пригодится. Мы можем создать двумерный массив комплексных чисел и запустить нашу функцию is_stable для каждого элемента. Результирующий массив будет иметь 1 для мандельбротов и 0 для нестабильных. Когда мы наносим этот массив как изображение — вуаля, у нас есть желаемый черно-белый визуал.

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

import numpy as np


def candidate_values(xmin, xmax, ymin, ymax, pixel_density):
    # Generate a 2D grid of real and imaginary values
    real = np.linspace(xmin, xmax, num=int((xmax-xmin) * pixel_density))
    imag = np.linspace(ymin, ymax, num=int((ymax-ymin) * pixel_density))
    
    # Cross each row of `xx` with each column of `yy` to create a grid of values
    xx, yy = np.meshgrid(real, imag)
    
    # Combine the real and imaginary parts into complex numbers
    matrix = xx + 1j * yy
    
    return matrix

Мы будем использовать функцию np.linspace для создания равномерно распределенных чисел в пределах диапазона. Параметр pixel_density динамически устанавливает количество пикселей на единицу.

Например, матрица с горизонтальным диапазоном (-2, 0), вертикальным диапазоном (-1,2, 1,2) и pixel_density из 1 будет иметь форму (2, 2). Это означает, что наше результирующее изображение Мандельброта будет иметь 2 пикселя в ширину и 2 пикселя в высоту, что заставит Бенуа Мандельброта перевернуться в гробу.

c = candidate_values(-2, 0, -1.2, 1.2, 1)

c.shape
(2, 2)

Итак, нам лучше использовать более высокую плотность, например 25:

c = candidate_values(-2, 0, -1.2, 1.2, 25)

c.shape
(60, 50)

Теперь, чтобы запустить нашу функцию is_stable для каждого элемента c, мы векторизируем ее с помощью np.vectorize и вызываем с 20 итерациями:

c = candidate_values(-2, 0.7, -1.2, 1.2, pixel_density=25)

mandelbrot_mask = np.vectorize(is_stable)(c, n_iterations=20)
mandelbrot_mask.shape
(60, 67)

Мы называем результирующий массив mandelbrot_mask, так как он возвращает True (1) для каждого мандельброта. Чтобы построить этот массив, мы используем функцию imshow Matplpotlib с цветовой картой binary. Это сделает изображение черно-белым.

import matplotlib.pyplot as plt

plt.imshow(mandelbrot_mask, cmap="binary")

# Turn off the axes and use tight layout
plt.axis("off")
plt.tight_layout()

Ну, это один уродливый Мандельброт. Как насчет того, чтобы увеличить плотность пикселей до 1024 и количество итераций до 30?

c = candidate_values(-2, 0.7, -1.2, 1.2, pixel_density=1024)

mandelbrot_mask = np.vectorize(is_stable)(c, n_iterations=30)

plt.imshow(mandelbrot_mask, cmap="binary")
plt.gca().set_aspect("equal")
plt.axis("off")
plt.tight_layout()

Вот это уже больше похоже! Поздравляем с построением вашего первого изображения Мандельброта!

Подождите, это было не искусство!

Несмотря на то, что наш текущий фрактал все еще выглядит очень круто, он далек от того искусства, которое я обещал.

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

Давайте начнем преобразование, организовав наш код в класс, потому что это настоящий беспорядок со всем разбросанным.

Имя класса будет Mandelbrot, и мы будем использовать классы данных, чтобы нам не пришлось создавать конструктор __init__, как пещерный человек:

from dataclasses import dataclass

@dataclass
class Mandelbrot: # Inspired by the Real Python article shared above
    n_iterations: int
    
    def is_stable(self, c: complex) -> bool:
        z = 0
    
        for _ in range(self.n_iterations):
            z = z ** 2 + c
            if abs(z) > 2:
                return False

        return True

Класс требует только инициализации параметра max_iteration. Мы также добавляем функцию is_stable в качестве метода класса.

mandelbrot = Mandelbrot(n_iterations=30)

mandelbrot.is_stable(0.1)
True
mandelbrot.is_stable(1 + 4.4j)
False

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

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

Используя эту информацию, мы можем придать каждому пикселю (комплексному числу) различную глубину цвета в зависимости от итерации, которую они завершают. Это называется алгоритмом Escape Count. Давайте реализуем это в нашем классе:

@dataclass
class Mandelbrot:
    max_iterations: int
    
    def escape_count(self, c: complex) -> int:
        z = 0
        for iteration in range(self.max_iterations):
            z = z ** 2 + c
            if abs(z) > 2:
                return iteration
        return self.max_iterations

Во-первых, мы меняем n_iterations на max_iterations, так как это имеет больше смысла. Затем мы создаем метод escape_count, который:

  • если c нестабилен, возвращает итерацию, в которой он превышает значение 2
  • если c стабильно, возвращает максимальное количество итераций
mandelbrot = Mandelbrot(max_iterations=50)

mandelbrot.escape_count(-0.1) # stable
50
mandelbrot.escape_count(0.26) # unstable
29

Теперь мы создаем еще один метод для измерения стабильности на основе количества итераций:

@dataclass
class Mandelbrot:
    max_iterations: int
    
    def escape_count(self, c: complex) -> int:
        z = 0
        for i in range(self.max_iterations):
            z = z ** 2 + c
            if abs(z) > 2:
                return i
        return self.max_iterations
    
    def stability(self, c: complex) -> float:
        return self.escape_count(c) / self.max_iterations

Метод stability возвращает меру от 0 до 1, которую мы позже можем использовать для определения глубины цвета. Только мандельброты будут возвращать max_iterations, поэтому они будут отмечены 1. Числам, близким к краю, потребуется больше времени, чтобы стать нестабильными, поэтому их значения будут все ближе к 1.

С помощью этой логики мы можем вернуть нашу функцию is_stable, но сделать ее намного короче:

@dataclass
class Mandelbrot:
    max_iterations: int
    
    def escape_count(self, c: complex) -> int:
        z = 0
        for i in range(self.max_iterations):
            z = z ** 2 + c
            if abs(z) > 2:
                return i
        return self.max_iterations
    
    def stability(self, c: complex) -> float:
        return self.escape_count(c) / self.max_iterations
    
    def is_stable(self, c: complex) -> bool:
        # Return True only when stability is 1
        return self.stability(c) == 1
mandelbrot = Mandelbrot(max_iterations=50)

mandelbrot.stability(-.1)
1.0
mandelbrot.is_stable(-.1)
True
mandelbrot.stability(2)
0.02
mandelbrot.is_stable(2)
False

Теперь мы создаем последний метод для plot набора с помощью Matplotlib:

@dataclass
class Mandelbrot:
    max_iterations: int
    
    # ... The rest of the code from above
    
    @staticmethod
    def candidate_values(xmin, xmax, ymin, ymax, pixel_density):
        real = np.linspace(xmin, xmax, num=int((xmax-xmin) * pixel_density))
        imag = np.linspace(ymin, ymax, num=int((ymax-ymin) * pixel_density))

        xx, yy = np.meshgrid(real, imag)
        matrix = xx + 1j * yy

        return matrix
    
    
    def plot(self, xmin, xmax, ymin, ymax, pixel_density=64, cmap="gray_r"):
        c = Mandelbrot.candidate_values(xmin, xmax, ymin, ymax, pixel_density)
        
        # Apply `stability` over all elements of `c`
        c = np.vectorize(self.stability)(c)
        
        plt.imshow(c, cmap=cmap, extent=[0, 1, 0, 1])
        plt.gca().set_aspect("equal")
        plt.axis('off')
        plt.tight_layout()

В plot мы применяем метод stability ко всем элементам c, поэтому результирующая матрица содержит глубину цвета в каждой ячейке. Когда мы строим эту матрицу с перевернутой шкалой оттенков серого (чтобы мандельброты оставались черными), мы получаем следующее изображение:

mandelbrot = Mandelbrot(max_iterations=30)

mandelbrot.plot(
    xmin=-2, xmax=0.5, 
    ymin=-1.5, ymax=1.5, 
    pixel_density=1024,
)

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

mandelbrot = Mandelbrot(max_iterations=30)

mandelbrot.plot(
    xmin=-2, xmax=0.5, 
    ymin=-1.5, ymax=1.5, 
    pixel_density=1024,
    cmap="gist_heat"
)

Обратите внимание на то, как граничные линии имеют ярко-красный цвет и как еще появляются белые пятна там, где набор повторяется. Потрясающий!

Заключение

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

Все эти задачи являются недостатками Matplotlib, но в следующей статье мы выйдем на совершенно новый уровень с Pillow, библиотекой обработки изображений Python.

"Следите за обновлениями"!

Понравилась эта статья и, скажем прямо, ее причудливый стиль написания? Представьте себе, что у вас есть доступ к десяткам таких же, написанных блестящим, обаятельным, остроумным автором (кстати, это я :).

Всего за 4,99 $ членства вы получите доступ не только к моим историям, но и к сокровищнице знаний от лучших и самых ярких умов на Medium. А если вы воспользуетесь моей реферальной ссылкой, то получите мою сверхновую благодарность и виртуальную пятерку за поддержку моей работы.