def sequence(c):
= 0
z while True:
yield z
= z**2 +c z
Appendix L — Mandelbrot Set in Python
\(~\)
Original Author: Bartosz Zaczyński
\(~\)
L.1 The Boundary of Iterative Stability
- Formally, \(\,\)the Mandelbrot set is the set of complex numbers, \(\color{red}{c}\), for which an infinite sequence of numbers, \(z_0\), \(z_1\), \(\cdots\), \(z_n\), \(\cdots\), remains bounded
\[\begin{aligned} z_0 &= 0\\ z_{n+1} &= z_n^2 + c \end{aligned}\]
- The entire Mandelbrot set fits in a circle with a radius of two when depicted on the complex plane. This is a handy fact that’ll let you skip many unnecessary calculations for points that certainly don’t belong to the set
for n, z in enumerate(sequence(c=1)):
print(f'z({n}) = {z}')
if n >= 9:
break
z(0) = 0
z(1) = 1
z(2) = 2
z(3) = 5
z(4) = 26
z(5) = 677
z(6) = 458330
z(7) = 210066388901
z(8) = 44127887745906175987802
z(9) = 1947270476915296449559703445493848930452791205
- Most numbers will make this sequence diverge to infinity. \(\,\)However, \(\,\)some will keep it stable by either converging the sequence to a single value or staying within a bounded range. Others will make the sequence periodically stable by cycling back and forth between the same few values. Stable and periodically stable values make up the Mandelbrot set
for n, z in enumerate(sequence(c=0)):
print(f'z({n}) = {z}')
if n >= 9:
break
z(0) = 0
z(1) = 0
z(2) = 0
z(3) = 0
z(4) = 0
z(5) = 0
z(6) = 0
z(7) = 0
z(8) = 0
z(9) = 0
for n, z in enumerate(sequence(c=-1)):
print(f'z({n}) = {z}')
if n >= 9:
break
z(0) = 0
z(1) = -1
z(2) = 0
z(3) = -1
z(4) = 0
z(5) = -1
z(6) = 0
z(7) = -1
z(8) = 0
z(9) = -1
It’s not obvious which numbers are stable and which aren’t, \(\,\)because the formula is sensitive to even the smallest change of the tested value, \(c\)
The fractal corresponding to the Mandelbrot set has a finite area estimated at
1.506484
square units. Mathematicians haven’t pinpointed the exact number yet and don’t know whether it’s rational or not. On the other hand, the perimeter of the Mandelbrot set is infinite
L.2 Plotting the Mandelbrot Set Using Python’s Matplotlib
- To generate the initial set of candidate values, \(~\)you can take advantage of
np.linspace()
, \(\,\)which creates evenly spaced numbers in a given range:
import numpy as np
'ignore')
np.warnings.filterwarnings(
def complex_matrix(xmin, xmax, ymin, ymax, pixel_density):
= np.linspace(xmin, xmax, int((xmax -xmin) *pixel_density))
re = np.linspace(ymin, ymax, int((ymax -ymin) *pixel_density))
im
return re[np.newaxis, :] + im[:, np.newaxis] *1j
def is_stable(c, num_iterations):
= 0
z for _ in range(num_iterations):
= z**2 +c
z return abs(z) <= 2
L.2.1 Low-Resolution Scatter Plot
import matplotlib.pyplot as plt
= complex_matrix(-2, 0.5, -1.5, 1.5, pixel_density=21)
c = c[is_stable(c, num_iterations=20)]
members
def plot_low_resolution_scatter():
=(6, 8))
plt.figure(figsize='black', marker='x', s=1)
plt.scatter(members.real, members.imag, color
'equal')
plt.gca().set_aspect('off')
plt.axis(
plt.tight_layout()
plt.show()
plot_low_resolution_scatter()
L.2.2 High-Resolution Black-and-White Visualization
= complex_matrix(-2, 0.5, -1.5, 1.5, pixel_density=512)
c
def plot_high_resolution_black_and_white():
=(6, 8))
plt.figure(figsize=20), cmap='binary')
plt.imshow(is_stable(c, num_iterations
'equal')
plt.gca().set_aspect('off')
plt.axis(
plt.tight_layout()
plt.show()
plot_high_resolution_black_and_white()
L.3 Drawing the Mandelbrot Set With Pillow
By replacing Matplotlib’s
plt.imshow()
with a very similar call toPillow
’s factory method:= Image.fromarray(~is_stable(c, num_iterations=20)) image # image.show() # for console # for jupyter notebook display(image)
Notice the use of the bitwise not operator (
~
) in front of your stability matrix, \(\,\)which inverts all of the Boolean values. \(\,\)This is so that the Mandelbrot set appears in black on a white background sincePillow
assumes a black background by default
L.3.1 Finding Convergent Elements of the Set
from dataclasses import dataclass
@dataclass
class MandelbrotSet:
int
max_iterations:
def __contains__(self, c: complex) -> bool:
= 0
z for _ in range(self.max_iterations):
= z**2 +c
z if abs(z) > 2:
return False
return True
= MandelbrotSet(max_iterations=30) mandelbrot_set
0.26 in mandelbrot_set
False
0.25 in mandelbrot_set
True
from PIL import Image
= 512, 512
width, height = 0.0055
scale = '1'
BLACK_AND_WHITE
= Image.new(mode=BLACK_AND_WHITE, size=(width, height))
image
for y in range(height):
for x in range(width):
= scale *complex(x -width /1.35, height /2 -y)
c not in mandelbrot_set) image.putpixel((x, y), c
display(image)
L.3.2 Measuring Divergence With the Escape Count
The number of iterations it takes to detect divergence is known as the escape count. \(\,\)We can use the escape count to introduce multiple levels of gray
However, \(\,\)it’s usually more convenient to deal with normalized escape counts so that their values are on a scale from zero to one regardless of the maximum number of iterations
@dataclass
class MandelbrotSet:
int
max_iterations:
def __contains__(self, c: complex) -> bool:
return self.stability(c) == 1
def stability(self, c: complex) -> float:
return self.escape_count(c) /self.max_iterations
def escape_count(self, c: complex) -> int:
= 0
z for iteration in range(self.max_iterations):
= z**2 +c
z if abs(z) > 2:
return iteration
return self.max_iterations
= MandelbrotSet(max_iterations=30) mandelbrot_set
0.25) mandelbrot_set.escape_count(
30
0.25) mandelbrot_set.stability(
1.0
0.25 in mandelbrot_set
True
0.26) mandelbrot_set.escape_count(
29
0.26) mandelbrot_set.stability(
0.9666666666666667
0.26 in mandelbrot_set
False
The updated implementation of the
MandelbrotSet
class allows for a grayscale visualization, which ties pixel intensity with stabilityBut you’ll need to change the pixel mode to
L
, \(\,\)which stands for luminance. \(\,\)In this mode, \(\,\)each pixel takes an integer value between0
and255
, \(\,\)so you’ll also need to scale the fractional stability appropriately:
= 512, 512
width, height = 0.0055
scale = 'L'
GRAYSCALE
= Image.new(mode=GRAYSCALE, size=(width, height))
image
for y in range(height):
for x in range(width):
= scale *complex(x -width /1.35, height /2 -y)
c = 1 -mandelbrot_set.stability(c)
instability int(instability *255)) image.putpixel((x, y),
display(image)
L.3.3 Smoothing Out the Banding Artifacts
Getting rid of color banding from the Mandelbrot set’s exterior boils down to using fractional escape counts.
One way to interpolate their intermediate values is to use logarithms
from math import log
@dataclass
class MandelbrotSet:
int
max_iterations: float = 2.0
escape_radius:
def __contains__(self, c: complex) -> bool:
return self.stability(c) == 1
def stability(self, c: complex, smooth=False, clamp=True) -> float:
= self.escape_count(c, smooth) /self.max_iterations
value return max(0.0, min(value, 1.0)) if clamp else value
def escape_count(self, c: complex, smooth=False) -> int or float:
= 0
z for iteration in range(self.max_iterations):
= z**2 +c
z if abs(z) > self.escape_radius:
if smooth:
return iteration +1 -log(log(abs(z))) /log(2)
return iteration
return self.max_iterations
= MandelbrotSet(max_iterations=20, escape_radius=1000.0)
mandelbrot_set
= 512, 512
width, height = 0.0055
scale = 'L'
GRAYSCALE
= Image.new(mode=GRAYSCALE, size=(width, height))
image for y in range(height):
for x in range(width):
= scale *complex(x -width /1.35, height /2 -y)
c = 1 -mandelbrot_set.stability(c, smooth=True)
instability int(instability *255)) image.putpixel((x, y),
display(image)
L.3.4 Translating Between Set Elements and Pixels
Unlike the logarithms before, \(\,\)the math for scaling and translating the image isn’t terribly difficult. However, \(\,\)it adds a bit of code complexity
You can build a smart pixel data type that’ll encapsulate the conversion between the coordinate systems, account for scaling, and handle the colors
@dataclass
class Viewport:
image: Image.Imagecomplex
center: float
width:
@property
def scale(self):
return self.width /self.image.width
@property
def height(self):
return self.scale *self.image.height
@property
def offset(self):
return self.center +complex(-self.width, self.height) /2
def __iter__(self):
for y in range(self.image.height):
for x in range(self.image.width):
yield Pixel(self, x, y)
@dataclass
class Pixel:
viewport: Viewportint
x: int
y:
@property
def color(self):
return self.viewport.image.getpixel((self.x, self.y))
@color.setter
def color(self, value):
self.viewport.image.putpixel((self.x, self.y), value)
def __complex__(self):
return complex(self.x, -self.y) *self.viewport.scale +self.viewport.offset
= MandelbrotSet(max_iterations=256, escape_radius=1000.0)
mandelbrot_set
= Image.new(mode='L', size=(512, 512))
image for pixel in Viewport(image, center=-0.7435 +0.1314j, width=0.002):
= complex(pixel)
c = 1 -mandelbrot_set.stability(c, smooth=True)
instability = int(instability *255) pixel.color
- The
viewport
spans0.002
world units and is centered at-0.7435 +0.1314j
, \(\,\)which is close to a Misiurewicz point that produces a beautiful spiral
display(image)
from PIL import ImageEnhance
= ImageEnhance.Brightness(image)
enhancer 1.4)) display(enhancer.enhance(
- We can find many more unique points producing such spectacular results. \(\,\)Wikipedia hosts an entire image gallery of various details of the Mandelbrot set that are worth exploring
= MandelbrotSet(max_iterations=256, escape_radius=1000.0)
mandelbrot_set
= Image.new(mode='L', size=(512, 512))
image for pixel in Viewport(image, center=-0.74364990 +0.13188204j, width=0.00073801):
= complex(pixel)
c = 1 -mandelbrot_set.stability(c, smooth=True)
instability = int(instability *255) pixel.color
= ImageEnhance.Brightness(image)
enhancer 1.4)) display(enhancer.enhance(
L.4 Making an Artistic Representation of the Mandelbrot Set
- While there are many algorithms for plotting the Mandelbrot set in aesthetically pleasing ways, \(\,\)our imagination is the only limit!
L.4.1 Color Palette
To use more colors, \(\,\)you’ll need to create your image in the RGB mode first, \(\,\)which will allocate 24 bits per pixel:
= Image.new(mode='RGB', size=(width, height)) image
From now on, \(\,\)
Pillow
will represent every pixel as a tuple comprised of the red, green, and blue (RGB) color channelsMatplotlib
library includes several colormaps with normalized color channels. \(\,\)Some colormaps are fixed lists of colors, \(\,\)while others are able to interpolate values given as a parameter
import matplotlib.cm
= matplotlib.cm.get_cmap('twilight').colors
colormap 5] colormap[:
[[0.8857501584075443, 0.8500092494306783, 0.8879736506427196],
[0.8837852019553906, 0.8507294054031063, 0.8872322209694989],
[0.8817223105928579, 0.8512759407765347, 0.8863805692551482],
[0.8795410528270573, 0.8516567540749572, 0.8854143767924102],
[0.8772488085896548, 0.8518702833887027, 0.8843412038131143]]
Pillow
only understands integers in the range of0
through255
for the color channels. \(\,\)We need another function that’ll reverse the normalization process to make the Pillow library happy:
def denormalize(colormap):
return [tuple(int(channel *255) for channel in color) for color in colormap]
= denormalize(colormap)
palette 5] palette[:
[(225, 216, 226),
(225, 216, 226),
(224, 217, 226),
(224, 217, 225),
(223, 217, 225)]
The
twilight
colormap is a list of 510 colors. \(\,\)After callingdenormalize()
on it, \(\,\)you’ll get a color palette suitable for your painting functionIf you’d like to test out a couple of different palettes, \(\,\)then it might be convenient to introduce a helper function to avoid retyping the same commands over and over again:
def paint(mandelbrot_set, viewport, palette, smooth):
for pixel in viewport:
= mandelbrot_set.stability(complex(pixel), smooth)
stability = int(min(stability *len(palette), len(palette) -1))
index = palette[index % len(palette)] pixel.color
- The number of colors in your palette doesn’t necessarily have to equal the maximum number of iterations. \(\,\)After all, it’s unknown how many stability values there’ll be until we run the recursive formula. \(\,\)When we enable smoothing, \(\,\)the number of fractional escape counts can be greater than the number of iterations!
= MandelbrotSet(max_iterations=512, escape_radius=1000.0)
mandelbrot_set = Image.new(mode='RGB', size=(512, 512))
image = Viewport(image, center=-0.7435 +0.1314j, width=0.002)
viewport =True)
paint(mandelbrot_set, viewport, palette, smooth
display(image)
- Feel free to try other color palettes included in
Matplotlib
or one of the third-party libraries that they mention in the documentation. \(\,\)Additionally, \(\,\)Matplotlib
lets you reverse the color order by appending the_r
suffix to a colormap’s name
= matplotlib.cm.get_cmap('twilight_r').colors
colormap = denormalize(colormap)
palette =True)
paint(mandelbrot_set, viewport, palette, smooth
display(image)
= Image.new(mode='RGB', size=(768, 768))
image = Viewport(image, center=-0.743643135 +0.131825963j, width= 0.000014628)
viewport
= matplotlib.cm.get_cmap("plasma").colors
colormap = denormalize(colormap)
palette =True)
paint(mandelbrot_set, viewport, palette, smooth
display(image)
- Suppose you wanted to emphasize the fractal’s edge. In such a case, you can divide the fractal into three parts and assign different colors to each:
= [(1, 1, 1)] *50
exterior = [(1, 1, 1)] *5
interior = [(1 - i /44,) *3 for i in range(45)]
gray_area = denormalize(exterior +gray_area +interior)
palette
= MandelbrotSet(max_iterations=20, escape_radius=1000.0)
mandelbrot_set = Viewport(image, center=-0.75, width=2.5)
viewport
=True)
paint(mandelbrot_set, viewport, palette, smooth
display(image)
L.4.2 Color Gradient
import numpy as np
from scipy.interpolate import interp1d
def make_gradient(colors, interpolation='linear'):
= [i /(len(colors) -1) for i in range(len(colors))]
X = [[color[i] for color in colors] for i in range(3)]
Y = [interp1d(X, y, kind=interpolation) for y in Y]
channels
return lambda x: [np.clip(channel(x), 0, 1) for channel in channels]
= (0, 0, 0)
black = (0, 0, 1)
blue = (0.5, 0, 0)
maroon = (0, 0, 0.5)
navy = (1, 0, 0)
red
= [black, navy, blue, maroon, red, black]
colors = make_gradient(colors, interpolation='cubic') gradient
= 256
num_colors = denormalize([gradient(i /num_colors) for i in range(num_colors)]) palette
= Image.new(mode='RGB', size=(768, 768))
image = MandelbrotSet(max_iterations=20, escape_radius=1000)
mandelbrot_set = Viewport(image, center=-0.75, width=2.5)
viewport
=True)
paint(mandelbrot_set, viewport, palette, smooth
display(image)
L.4.3 Color Model
- There are alternative color models that let you express the same concept. \(\,\)One is the Hue, Saturation, Brightness (HSB) color model, also known as Hue, Saturation, Value (HSV)
The three HSB coordinates are:
- Hue: The angle measured counterclockwise between
0°
and360°
- Saturation: The radius of the cylinder between
0%
and100%
- Brightness: The height of the cylinder between
0%
and100%
To use such coordinates in
Pillow
, \(~\)we must translate them to a tuple of RGB values in the familiar range of0
to255
:- Hue: The angle measured counterclockwise between
from PIL.ImageColor import getrgb
def hsb(hue_degrees: int, saturation: float, brightness: float):
return getrgb(
f"hsv({hue_degrees % 360},"
f"{saturation *100}%,"
f"{brightness *100}%)"
)
= Image.new(mode='RGB', size=(768, 768))
image = MandelbrotSet(max_iterations=20, escape_radius=1000.0)
mandelbrot_set
for pixel in Viewport(image, center=-0.75, width=2.5):
= mandelbrot_set.stability(complex(pixel), smooth=True)
stability = (0, 0, 0) if stability == 1 else hsb(
pixel.color =int((1 - stability) * 360),
hue_degrees=1 - stability,
saturation=1,
brightness
)
display(image)
L.5 Conclusions
In this appendix, \(\,\)we learned how to:
- Apply complex numbers to a practical problem
- Find members of the Mandelbrot set
- Draw these sets as fractals using
Matplotlib
andPillow
- Make a colorful artistic representation of the fractals
Now we know how to use Python to plot and draw the famous fractal discovered by Benoît Mandelbrot