5

Ive written a function which uses turtle to draw a Mandelbrot set, however its very slow (~10's minutes) for any decent resolution, how could I make my code more efficient?

import math, turtle

screen = turtle.Screen()
screen.setup(width=1000, height=600 ,startx=0, starty=0)
screen.bgcolor('black')
screen.tracer(0)
pen = turtle.Turtle()
pen.penup()
pen.speed(10)
width = 800
height = 600
MAX_ITER=80

def draw_mandelbrot(c):
  z = 0
  n = 0
  while abs(z) <= 2 and n < MAX_ITER:
      z = z * z + c
      n += 1
  return n

def generate_mandelbrot_set(StartR, StartI, EndR, EndI):
  screen.setworldcoordinates(StartR, StartI, EndR, EndI)
  screen.bgcolor("black")
  for x in range(width):
      for y in range(height):
          c = complex(StartR + (x / width) * (EndR - StartR), StartI + (y / height) * (EndI - StartI))
          m = draw_mandelbrot(c)
          color = (255 - int(m * 255 / MAX_ITER)) / 255
          pen.goto(StartR + (x / width) * (EndR - StartR), StartI + (y / height) * (EndI - StartI))
          if y == 0:
            pen.pendown()
          elif y == height-1 :
            pen.penup()
          pen.color(color, color, color)
          pen.dot()

generate_mandelbrot_set(-2,-1,1,1)

screen.update()
screen.mainloop()

Im new to coding so any advice would be hugely appreciated!

4
  • 2
    I believe the cause is likely that almost all of the processing is going into using a turtle to draw pixels. Turtles are meant for vector graphics. You'll want an image processing library to draw pixels. Maybe Pillow. Commented Feb 20, 2024 at 22:18
  • 1
    The other problem will be that you're doing all the calculations in native python. Python is one of the slower languages and is usually better when used to orchestrate compiled modules rather than trying to do those tasks directly. Folks usually use libraries like numpy and related to assist with vector-like calculations. Commented Feb 20, 2024 at 22:26
  • @Ouroborus You read my mind :D Commented Feb 21, 2024 at 1:17
  • With possibly billions of computations to be made when making a zoom, execution time matters very much. My fastest solutions did actually use a turtle algorithm I made to follow the edges of equal-depth areas, and then fill the enclosed area, instead a laborious raster trudge iterating on every pixel. That only works in 'flat' areas, not in the neighbourhood of the M-Set, but a similar approach can be used for the vast areas inside the set. Commented Feb 21, 2024 at 13:50

5 Answers 5

3

Like its animal namesake, turtle is very, very slow. It is only ever used as an educational tool, to let the learners see in real time how their algorithm translates to the turtle's movements. It is thus, unfortunately, pointless to try to speed up the code where a turtle poops out an image pixel by excruciating pixel. To get it up to speed (hehe), better tooling is required.

Jean-François Puget made an excellent comparison of various ways to generate the Mandelbrot set in Python. Among them, there is code for using GPU, code for using compiled Python... I chose to show you the numpy method, i.e. using the tools that most intermediate Python programmers might be familiar with, without going overboard. numpy is a library that does array calculations outside Python, and is thus able to do them very, very fast. Also, I switched the display library to pillow, a popular image processing library. Together, numpy and pillow let you do this quite fast.

As you said you were new to coding, a lot of this might be a bit advanced, but I wanted to show how actual production code might solve this issue, poossibly inspire you to see what is possible. Also, given that you are messing with Mandelbrot, I suspect you might have sufficient basis in mathematics to intuitively grasp the array operations numpy is providing here, even if you are just starting coding.

# pip install pillow numpy

from PIL import Image
import numpy as np

# Adapted from https://gist.github.com/jfpuget/60e07a82dece69b011bb

def mandelbrot(cmin, cmax, width, height, maxiter):
    """
    cmin: bottom left corner, as a complex number
    cmax: top right corner, as a complex number
    width: width of the resultant array
    height: height of the resultant array
    maxiter: maximum number of iterations
    """

    # make the real axis as an array of shape (width)
    real = np.linspace(cmin.real, cmax.real, width, dtype=np.float32)
    # make the imaginary axis as an array of shape (height)
    imag = np.linspace(cmin.imag, cmax.imag, height, dtype=np.float32) * 1j
    # combine them into a complex array of shape (width, height)
    c = real + imag[:, None]

    # make the output array of the same shape, fill it with int zeroes
    output = np.zeros(c.shape, dtype='uint16')
    # and a z array of the same shape, fill it with complex zeroes
    z = np.zeros(c.shape, np.complex64)
    # do the mandelbrotty thing:
    for i in range(maxiter):
        # make a bool array of the same shape showing where `z` is within range
        notdone = np.less(z.real * z.real + z.imag * z.imag, 4.0)
        # at the places where `notdone` is true,
        # update the current iteration number;
        # the places where `z` got out of range will be unaffected
        output[notdone] = i
        # update `z` in those same places
        z[notdone] = z[notdone] ** 2 + c[notdone]
    # make the center zero for nice contrast
    output[output == maxiter-1] = 0

    return output

cmin, cmax = -2-1j, 1+1j
width, height = 800, 600
maxiter = 80
# calculate the mandelbrot set
m = mandelbrot(cmin, cmax, width, height, maxiter)
# calculate the greyscale pixels
pixels = (m * 255 / maxiter).astype('uint8')
# make the greyscale image
img = Image.fromarray(pixels, 'L')
# and let us see it
img.show()

It takes my Macbook Pro 23ms to generate the mandelbrot array and then the image with the same parameters as yours.

Sign up to request clarification or add additional context in comments.

Comments

2

I tried your code with a 80x60 resolution and I really liked the look of the little vertical lines and dots. Doing the Mandelbrot set in turtle graphics is impressive! You could speed it up by using screen.tracer(0, 0) and pen.speed('fastest') (which is faster than 10) for some minimal gains.

But at 800x600, the turtle is the wrong tool for the job: not only the artistic lines won't be visible, Python will spend a lot of time doing cute things like animating the turtle movements (which is only disabled at speed 11).

My suggestion would be to ditch the turtle and use a graphical interface with a canvas object, where you can place individual pixels. Python comes with Tkinter, a simple but efficient library that can do this.

Here's the exact same rendering logic, translated to Tkinter, finishing in under 7 seconds:

from tkinter import Tk, Canvas, PhotoImage, mainloop

WIDTH = 800
HEIGHT = 600
MAX_ITER = 80

def draw_mandelbrot(c):
    z = 0
    n = 0
    while abs(z) <= 2 and n < MAX_ITER:
        z = z * z + c
        n += 1
    return n

def generate_mandelbrot_set(StartR, StartI, EndR, EndI):
    img = PhotoImage(width=WIDTH, height=HEIGHT)
    for x in range(WIDTH):
        for y in range(HEIGHT):
            c = complex(
                StartR + (x / WIDTH) * (EndR - StartR),
                StartI + (y / HEIGHT) * (EndI - StartI),
            )
            m = draw_mandelbrot(c)
            color = 255 - int(m * 255 / MAX_ITER)
            color_str = "#{0:02x}{0:02x}{0:02x}".format(color)
            img.put(color_str, (x, y))
    return img

window = Tk()
canvas = Canvas(window, width=WIDTH, height=HEIGHT, bg="#000000")
canvas.pack()

img = generate_mandelbrot_set(-2, -1, 1, 1)

# Important: don't add the image to the canvas until it's fully generated.
# Otherwise each added pixel will cause the canvas to re-render.
canvas.create_image((WIDTH / 2, HEIGHT / 2), image=img, state="normal")

mainloop()

high resolution mandelbrot set in a Tkinter window

1 Comment

Once you use tracer(0), I don't think fastest(10) matters any more since turtle's internal update loop is disabled.
1

I've attempted to speed up your program by approaching the problem slightly more as vectors instead of pixels based on the comment by @Ouroborus (+1):

from turtle import Screen, Pen

MAX_ITER = 80
WIDTH, HEIGHT = 800, 600

def compute_mandelbrot(c):
    z = complex(0)
    n = 0

    while n < MAX_ITER and abs(z) <= 2.0:
        z = z * z + c
        n += 1

    return n

def generate_mandelbrot_set(StartR, StartI, EndR, EndI):
    screen.setworldcoordinates(StartR, StartI, EndR, EndI)

    pen = Pen(visible=False)
    pen.penup()
    pen.setheading(90)

    for x in range(WIDTH):
        real = StartR + (x / WIDTH) * (EndR - StartR)
        pen.goto(real, StartI)
        pen.pendown()

        for y in range(HEIGHT):
            c = complex(real, StartI + (y / HEIGHT) * (EndI - StartI))
            m = compute_mandelbrot(c)
            gray = 1 - m / MAX_ITER
            pen.color(gray, gray, gray)
            pen.forward((EndI - StartI) / HEIGHT)

        pen.penup()
        screen.update()

screen = Screen()
screen.setup(WIDTH, HEIGHT, startx=0, starty=0)
screen.bgcolor('black')
screen.tracer(0)

generate_mandelbrot_set(-2, -1, 1, 1)

screen.mainloop()

However, you'll notice that it introduces some line artifacts:

enter image description here

These are due to a bug in the turtle's line drawing logic discussed here:

Why is turtle lightening pixels?

How do I control turtle's self._newline()?

Comments

0

With numba / jit, a faster implementation from here:

import numba 
import cmasher as cmr
import numpy as np
import matplotlib.pylab as plt

@numba.jit(nopython=True)
def in_main_cardioid(c):
    q = (c.real - 1/4)**2 + c.imag**2
    return q*(q + (c.real - 1/4)) <= c.imag**2/4

@numba.jit(nopython=True)
def in_period2bulb(c):
    return (c.real + 1)**2 + c.imag**2 <= 1/16
    
@numba.vectorize(nopython=True)
def mandelbrot(c, maxiter):
    # --> Check if point is in main cardioid.
    if in_main_cardioid(c): return maxiter
    if in_period2bulb(c): return maxiter
    
    # --> If not, check if it is nonetheless in the
    #     Mandelbrot set.
    x, y = c.real, c.imag
    x2, y2 = x*x, y*y
    for i in range(maxiter):
        if (x2 + y2) > 4:
            return i + 1 - np.log(np.log(x2+y2))/np.log(2)
            
        y = 2*x*y + c.imag
        x = x2 - y2 + c.real
        x2, y2 = x*x, y*y

    return maxiter

cr, ci = np.linspace(-2.25, 0.75, 1200), np.linspace(-1.25, 1.25, 900)
c = cr[:, None] + 1j*ci[None, :] # Broadcasting trick.
%timeit M = mandelbrot(c, 100); plt.imshow(M.T, cmap='seismic')

enter image description here

Comments

-1

Your code has two main problems: firstly, turtle isn't appropriate for drawing fractals, because of how slowly it draws. You should really use some other module like pygame. secondly, also python is slow calculating. I would suggest you use the module Numba, or a faster language like C. Here is the Numba documentation: https://numba.pydata.org/numba-doc/latest/index.html. You may need to split the main function in half and use one of them with numba for the calculation and the other one to display it.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.