Numpy Features – Plotting Julia and Mandlebrot sets

Numpy arrays have many powerful features. Some of these are fairly obvious, others are a little more obscure. This article contains demonstrations of how to use a few of these features to assist in plotting fractals, in particular Julia sets and the Mandlebrot set.

The Mandlebrot set
The Mandlebrot set

Firstly a little background. The Mandlebrot set arises from iterating within the complex  plane using the formula:

f(Z)= Z^2 + C

For the Mandlebrot set Z is initialised at 0, while C corresponds to the co-ordinates under consideration in the complex plane. Points which remain close to 0 indefinitely under iteration are part of the Mandlebrot set, while points which eventually tend towards infinity under iteration  are not part of the set. Generally when plotted on computer, the parts of the complex plane which are not included in the Mandlebrot set are colour coded by how many iterations it takes their absolute value to exceed 2 (the point of no return after which they must necessarily keep increasing) giving rise to the attractive images of the set (which is actually the plain coloured object including the origin of the complex plane.

Julia sets cover a wide range of functions . Here we shall just consider those Julia sets which share the same iterative formula as the Mandlebrot set. Julia sets iterate in the same way but are calculated from a different initial condition, that being that Z corresponds to the co-ordinates under consideration in the complex plane while C is a constant (0 for the Julia sets we will consider). Effectively Z and C are swapped for Julia sets compared to the Mandlebrot set. This means that while there is just one Mandlebrot set, there is a different Julia set for every point in the complex plane.

A Julia set
A Julia set

So how do you create a program to plot these fractals. As you may have gathered from the title of this article, I intend to demonstrate some useful features of Numpy which make this nice and easy.

Lets start by considering what packages and variables we will need

'''code to create image files for Julia and Mandlebrot set plots'''
import numpy as np # to calculate the images
import matplotlib.pyplot as plt # to print the images
import os # to save the images
import cv2 # to convert the images to movies
width, height = 2048, 2048 # image size
frames = 1 # 1 produces a single still, higher integers produce a series of images (movies too if > 24)

magnification_start = 1024 # zoom factor for first image
magnification_end = 1024.0 # zoom factor for final image
real_start, imaginary_start = -0.0, 0.0 # centre of coordinates for first image plot
real_end, imaginary_end = -0.0, 0.0 # coordinates of final image plot
real_seed_start, imaginary_seed_start = -0.5, 0.5 # starting seed for Julia sets
real_seed_end, imaginary_seed_end = -0.5, 0.1 # ending seed for Julia sets
max_iterations = 200 # the depth of iteration to use for each plot (higher gives more detail but takes longer)

# variables controlling set selection and output
call_julia, call_mandlebrot = True, False # flags to control which sets are calculated
save, display = True, False # flags to control saving and on screeen display options
julia_directory = r"C:\Users\Justin\Pictures\JuliaSets\Exp"
julia_title = "_julia_set"
mandlebrot_directory = r"C:\Users\Justin\Pictures\MandlebrotSets\Exp"
mandlebrot_title = "_mandlebrot_set"

colour_maps = ['hot', 'cool', 'prism', 'rainbow'] # list of available matplotlib plot colours
current_colour_map = 0 # to select from colour_maps

These variable should give us everything we need to create images or even movies of Julia sets or the Mandlebrot set.

Now we need to iterate for each image we want to create, passing the starting conditions into functions to create the images we desire, then we can pass the output of these functions to an image plotting and saving function. Finally we can call another function to stitch movies together if required.

# check if we are processing a still and if so set end values = start values
if frames == 1:
    magnification_end, real_end, imaginary_end = magnification_start, real_start, imaginary_start
# cycle through each frame in turn
for i in range (frames):
    # set the current scaling for the frame
    magnification_current = (magnification_start*(frames-i)/(frames) 
                             + magnification_end*i/(frames))
    # set the current locus for the image
    complex_current = complex((real_start*(frames-i)+real_end*i)/frames, 
                              (imaginary_start*(frames-i)+imaginary_end*i)/frames)
    # calculate and output the correct types of plot
    if call_julia == True:
        # create the starting seed for Julia sets
        complex_seed = complex((real_seed_start*(frames-i)+real_seed_end*i)/frames, 
                              (imaginary_seed_start*(frames-i)+imaginary_seed_end*i)/frames)
        # then change our minds and do the complicated one
        a = 2*i*math.pi/frames
        complex_seed = 0.7885*math.e**(complex(0, a))
        # get our calculated julia plot
        output_to_plot = julia_calculate(complex_current, 
                                         magnification_current, width, height, max_iterations)
        # and plot it
        plot_image(output_to_plot, colour_maps[current_colour_map],
                   display, save, julia_directory, julia_title, i)
    if call_mandlebrot == True:
        # get our calculated mandlebrot plot
        output_to_plot = mandlebrot_calculate(complex_current, 
                                              magnification_current, width, height, max_iterations)
        # and plot it
        plot_image(output_to_plot, colour_maps[current_colour_map], 
                   display, save, mandlebrot_directory, mandlebrot_title, i)
        
# if we created more than 24 images and saved them we likely want a movie
if frames > 24:
    if call_julia == True and save == True:
        # make a julia movie
        make_movie (julia_directory, julia_title)
    if call_mandlebrot == True and save == True:
        # make a mandlebrot movie
        make_movie (mandlebrot_directory, mandlebrot_title)

OK so now to calculate the functions, this is where Numpy becomes extremely useful. We are going to use a few neat features

  • np.linspace to set up some arrays of values representing the real and imaginary values of the points in the complex plane
  • np.tile to turn the 1 dimensional real and imaginary arrays into a 2D array of complex values
  • create a boolean array to use as a mask (which will itself be updated by masking)

Here is the code for a Mandlebrot function

def mandlebrot_calculate(complex_current, magnification_current, width, height, max_iterations):
    '''Calculates a plot of a particular part of the mandlebrot set detemined by the magnification level 
    and a set of complex coordinates to home in on, the resolution of the plot is determined by width and height
    detail is determined by the max_iterations'''
    # set up arrays to hold the image data (reshape is needed to avoid (1,) arrays)
    x = np.linspace (complex_current.real-(width/magnification_current),
                     complex_current.real+(width/magnification_current), width).reshape(1, width)
    y = np.linspace (complex_current.imag-(height/magnification_current), 
                     complex_current.imag+(height/magnification_current), height).reshape(height, 1)
    #initialise Z and C from the Mandlebrot set equation across the matrix
    # NOTE That Z and C are almost the reverse of each other for julia and mandlebrot sets
    Z = np.zeros((height, width), dtype=complex) # Z starts at 0 for the mandlebrot set (but use complex 0)
    C = np.tile(x, (height, 1)) + 1j*np.tile(y, (1, width)) # C is the local complex coordinates for the mandlebrot set
    # use a boolean mask to let us know when values have escaped and should not receive furhter updates
    mask = np.full ((height, width), True, dtype=bool)
    mandlebrot_plot = np.zeros ((height, width))
    for i in range (max_iterations):
        #calculate Z wherever the mask indicates it has not yet escaped above 2
        Z[mask] = Z[mask]*Z[mask]+C[mask]
        # update the mask with newly escaped areas above 2
        mask[np.abs(Z) > 2] = False
        #update the output plot with the latest values
        mandlebrot_plot[mask] = i
    #increase the contrast between the Mandlebrot set and the last iteration
    mandlebrot_plot[mask] = 0
    return mandlebrot_plot

While extremely useful, the application of linspace and tile is intuitive and relatively straightforward. Linspace produces an evenly spaced array of values across the interval we specify. We can do this for our real and imaginary ranges and then tile each 1D array on a different dimension to give our values of C for the Mandlebrot calculations. the Z array is even easier to set up as Z starts at zero everywhere for the Mandlebrot set, we just need to ensure that we initialise it as a complex array for future iterations.

The more interesting feature is using boolean masks with Numpy arrays, we shall do this in two places. First we initialise a boolean mask called ‘mask’ setting all values to True. Then when we iterate through the steps of the Mandlebrot calculation. After each iteration we calculate the absolute value of each complex number we have calculated. If it exceeds 2 then we know that point is not part of the Mandlebrot set and can cease calculating, but how can we represent this? Well using a boolean mask achieves this objective rather neatly. The syntax to use a boolean mask is array_to_be_masked[boolean_mask_array]. Rather than conventional ranges being selected as with normal array addressing, the boolean mask refers to all places in the target array where the mask array has a value of True.

First we shall update the mask array by using a mask on it to update escaped points to False:

mask[np.abs(Z) > 2] = False

Once we have updated the mask, we can use it to stencil the iteration number onto our output array using

mandlebrot_plot[mask] = i

This means that each iteration the area exposed by the mask will shrink until eventually it would only contain the points comprising the mandlebrot set (though obviously due to resolution and iteration depth we only get an approximation of this). Then we can pass the completed array of the iteration at which a particular point exceeded an absolute value of two back from the function.

The Julia set function is almost identical, but note that the C and Z set initialisations are reversed. Also note that while the ability to track and zoom is preserved for Julia sets, I have found it preferable to restrict movies to one or other action for Julia sets. By setting the Julia set Seed equal to the focus for a corresponding Mandlebrot set, the interesting visual interrelation between the two sets can be investigated.

def julia_calculate (complex_current, magnification_current, width, height, max_iterations):
    '''Calculates a plot of a particular julia set detemined by a particular complex seed
    magnification determines zoom, the resolution of the plot is determined by width and heigh
    detail is determined by the max_iterations'''
    # set up arrays to hold the image data (reshape is needed to avoid (1,) arrays)
    x = np.linspace (complex_current.real-(width/magnification_current),
                     complex_current.real+(width/magnification_current), width).reshape(1, width)
    y = np.linspace (complex_current.imag-(height/magnification_current), 
                     complex_current.imag+(height/magnification_current), height).reshape(height, 1)
    #initialise Z and C from the Julia set equation across the matrix
    # NOTE That Z and C are almost the reverse of each other for julia and mandlebrot sets
    Z = np.tile(x, (height, 1)) + 1j*np.tile(y, (1, width)) # Z is the local complex values for Julia sets
    C = np.full((height, width), complex_seed) # C is the starting complex seed
    # use a boolean mask to let us know when values have escaped and should not receive furhter updates
    mask = np.full ((height, width), True, dtype=bool)
    julia_plot = np.zeros ((height, width))
    for i in range (max_iterations):
        #calculate Z wherever the mask indicates it has not yet escaped above 2
        Z[mask] = Z[mask]*Z[mask]+C[mask]
        # update the mask with newly escaped areas above 2
        mask[np.abs(Z) > 2] = False
        #update the output plot with the latest values
        julia_plot[mask] = i
    #increase the contrast between the Julia set and the last iteration
    julia_plot[mask] = i
    return julia_plot

Now we have the arrays containing the number of iterations before a given point exceeds abs(z)=2. Lets pass these to a plotting function which will use MatPlotLib to generate us some pretty pictures. We don’t need axes  or scale bars so this code is reasonably straightforward.

 

def plot_image(plot_to_show, colour_map, display, save, directory, plot_title, image_number, dpi = 72):
    '''Takes an input numpy array and optionally plots (dependent on printout boolean)and saves (dependent on save boolean) 
    an image via the matplotlib library. colour mappings, save directories and image titles may be set. 
    The function also takes an image number so that sequential images can easily be saved to make moview'''
    fractal = plt.figure()
    fractal.set_size_inches(plot_to_show.shape[1]/dpi, plot_to_show.shape[0]/dpi)
    axes = plt.Axes(fractal, [0., 0., 1., 1.])
    axes.set_axis_off() # we don't actually want to see the axes
    fractal.add_axes(axes)
    # plot the figure - note we need to flip the picture to have it correct on the Y axis
    plt.imshow(np.flipud(plot_to_show), cmap=colour_map)
    # save the image if required using serial indexing to allow easy movie making
    if save and image_number < 10000:
        os.chdir(directory)
        if i < 10:
            plt.savefig('000'+str(image_number)+plot_title+'.png')
        elif i < 100:
            plt.savefig('00'+str(image_number)+plot_title+'.png')
        elif i < 1000:
            plt.savefig('0'+str(image_number)+plot_title+'.png')
        else:
            plt.savefig(str(image_number)+plot_title+'.png')
    if not display:
        # hide unwanted plots
        plt.close()

This displays or saves our fractal image files as desired. Finally if we produced a lot of image frames then we should make a movie of the resulting images. we shall do this using OpenCV. Note that while OpenCV can be installed via conda install opencv or pip install opencv, the import call in Python is import cv2 even though the latest version is now OpenCV3! Here is a function to create our movies

 
def make_movie (source_directory, video_title):
    '''Given a source directory and a vidoe title, creates an mp4 video comprised of 
    all the png images in the directory and writes it to the directory'''
    os.chdir(source_directory)
    video_title = "movie"+video_title+".mp4"
    # grab a list of the names of all our images
    images = [image for image in os.listdir(source_directory) if image.endswith(".png")]
    # take the first image and process into a numpy array
    picture_size = cv2.imread(os.path.join(source_directory, images[0]))
    # use that to get the dimensions for our video
    height, width, channels = picture_size.shape
    # Define the codec and create VideoWriter object
    codec = cv2.VideoWriter_fourcc(*'mp4v') # Be sure to use lower case
    # initialise a new movie
    movie = cv2.VideoWriter(video_title, codec, 24, (width,height))
    # process each image in turn
    for image in images:
        # writing it to our movie file
        movie.write(cv2.imread(os.path.join(source_directory, image)))
    #cv2.destroyAllWindows()
    movie.release()

Putting all this together and picking appropriate values for location, magnitude and seed values gives movies like these:

As usual the code can be found on Github. The code is very highly commmented for ease of understanding by those just starting with Python

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.