Build a Mandelbrot Set Plotter in Python: Step-by-Step GuideThe Mandelbrot set is one of the most famous fractals — a simple iterative rule that produces infinitely complex, self-similar patterns. This guide walks you through building a configurable, well-documented Mandelbrot set plotter in Python. You’ll learn the math, implement the algorithm, optimize performance, add color mapping, and create interactive zooming and exporting features.
What you’ll learn
- The mathematical definition of the Mandelbrot set
- How to implement the iteration and escape-time algorithm in Python
- Performance improvements (vectorization, numba, multiprocessing, GPU options)
- Coloring techniques and smooth coloring for pleasing visuals
- Building a simple GUI and interactive zoom/export features
- Example code and practical tips for exploration
Prerequisites
- Basic knowledge of Python (functions, loops, arrays)
- Familiarity with NumPy and Matplotlib is helpful
- Optional: numba, Pillow, PyQt or tkinter for GUI, and cupy or pyopencl for GPU acceleration
1. Mathematical background
The Mandelbrot set is the set of complex numbers c for which the sequence defined by the iteration
z_{n+1} = z_n^2 + c, with z_0 = 0
remains bounded (does not diverge to infinity). In practice we test whether |z_n| exceeds a bailout radius (commonly 2). If |z_n| > 2 at iteration n, the point is considered to escape; the iteration count gives an indication of how quickly it diverged and is used for coloring.
Key parameters:
- Complex plane region: typically real range [-2.5, 1] and imaginary range [-1.5, 1.5]
- Image resolution: width × height in pixels
- Max iterations: higher values reveal finer detail but cost more computation
- Bailout radius: commonly 2 (since |z| > 2 guarantees divergence)
2. Basic CPU implementation (simple, clear)
Start with a straightforward, easy-to-understand implementation using pure Python and NumPy for array handling.
# mandelbrot_basic.py import numpy as np import matplotlib.pyplot as plt def mandelbrot(width, height, x_min, x_max, y_min, y_max, max_iter): # Create coordinate grid xs = np.linspace(x_min, x_max, width) ys = np.linspace(y_min, y_max, height) X, Y = np.meshgrid(xs, ys) C = X + 1j * Y Z = np.zeros_like(C, dtype=np.complex128) output = np.zeros(C.shape, dtype=np.int32) for i in range(1, max_iter + 1): mask = np.abs(Z) <= 2.0 Z[mask] = Z[mask] * Z[mask] + C[mask] escaped = mask & (np.abs(Z) > 2.0) output[escaped] = i return output if __name__ == "__main__": img = mandelbrot(800, 600, -2.5, 1.0, -1.25, 1.25, 200) plt.imshow(img, cmap='hot', extent=[-2.5, 1.0, -1.25, 1.25]) plt.colorbar(label='Iterations to escape (0 = inside)') plt.title('Mandelbrot Set (basic)') plt.show()
Notes:
- The output array stores the iteration count at the escape moment; points that never escaped remain 0 (inside the set).
- This simple loop is easy to understand but not the fastest.
3. Improving performance
Large images and high iteration counts need optimizations.
Options:
- NumPy vectorization (as above) reduces Python loop overhead but still performs many array ops.
- Numba JIT-compilation gives near-C speeds for pure-Python loops.
- Multiprocessing splits rows/tiles across CPU cores.
- GPU (CuPy, PyOpenCL) can dramatically accelerate for large images.
Numba example:
# mandelbrot_numba.py import numpy as np import matplotlib.pyplot as plt from numba import njit, prange @njit(parallel=True, fastmath=True) def compute_mandelbrot(width, height, x_min, x_max, y_min, y_max, max_iter): out = np.zeros((height, width), np.int32) for j in prange(height): y = y_min + (y_max - y_min) * j / (height - 1) for i in range(width): x = x_min + (x_max - x_min) * i / (width - 1) c_re, c_im = x, y z_re, z_im = 0.0, 0.0 iter_count = 0 while z_re*z_re + z_im*z_im <= 4.0 and iter_count < max_iter: # (z_re + i z_im)^2 = (z_re^2 - z_im^2) + i(2*z_re*z_im) tmp = z_re*z_re - z_im*z_im + c_re z_im = 2.0*z_re*z_im + c_im z_re = tmp iter_count += 1 out[j, i] = iter_count return out if __name__ == "__main__": img = compute_mandelbrot(1920, 1080, -2.5, 1.0, -1.0, 1.0, 1000) plt.imshow(img, cmap='viridis', extent=[-2.5, 1.0, -1.0, 1.0]) plt.title('Mandelbrot (numba)') plt.show()
Tips:
- Use prange for parallel loops.
- fastmath can speed float ops at the expense of strict IEEE behavior.
- Warm up Numba (first run compiles).
GPU option: CuPy can replace NumPy arrays with GPU arrays and use similar code. For large resolutions, GPU offers big speedups.
4. Coloring: ugly vs. smooth
Simple coloring uses the raw iteration count mapped to a colormap. This creates banding. Smooth coloring gives continuous gradients using fractional escape time:
nu = n + 1 – log(log|z_n|)/log 2
Where n is the integer iteration at escape and z_n is the value at escape. This produces smoother transitions and more detail.
Example of smooth coloring applied to the output:
# smoothing after compute_mandelbrot import numpy as np def smooth_coloring(iter_counts, z_abs_values, max_iter): # iter_counts: integer escape iterations (0 if inside) # z_abs_values: |z| at escape (same shape) h = iter_counts.astype(np.float64) mask = (iter_counts > 0) & (iter_counts < max_iter) h[mask] = h[mask] + 1 - np.log(np.log(z_abs_values[mask])) / np.log(2) # Inside points keep 0 (or max value) return h
Combine with matplotlib’s colormaps (viridis, plasma, inferno) or create custom palettes. Perceptually-uniform maps (viridis, magma) often look better.
5. Interactive zoom and GUI
A minimal interactive viewer can be built with matplotlib’s event handling or with a GUI toolkit (tkinter, PyQt, DearPyGui).
Matplotlib example (click-and-drag to zoom):
# mandelbrot_zoom.py import numpy as np import matplotlib.pyplot as plt from mandelbrot_numba import compute_mandelbrot class MandelbrotViewer: def __init__(self, width=800, height=600): self.width, self.height = width, height self.x_min, self.x_max = -2.5, 1.0 self.y_min, self.y_max = -1.25, 1.25 self.max_iter = 400 self.fig, self.ax = plt.subplots() self.ax.set_title('Drag to zoom, right-click to reset') self.im = None self.rect_start = None self.cid_press = self.fig.canvas.mpl_connect('button_press_event', self.on_press) self.cid_release = self.fig.canvas.mpl_connect('button_release_event', self.on_release) self.draw() def draw(self): img = compute_mandelbrot(self.width, self.height, self.x_min, self.x_max, self.y_min, self.y_max, self.max_iter) if self.im is None: self.im = self.ax.imshow(img, cmap='magma', extent=[self.x_min, self.x_max, self.y_min, self.y_max]) self.fig.colorbar(self.im, ax=self.ax) else: self.im.set_data(img) self.im.set_extent([self.x_min, self.x_max, self.y_min, self.y_max]) self.fig.canvas.draw_idle() def on_press(self, event): if event.button == 3: # right-click reset self.x_min, self.x_max = -2.5, 1.0 self.y_min, self.y_max = -1.25, 1.25 self.draw() return self.rect_start = (event.xdata, event.ydata) def on_release(self, event): if self.rect_start is None: return x0, y0 = self.rect_start x1, y1 = event.xdata, event.ydata self.x_min, self.x_max = min(x0, x1), max(x0, x1) self.y_min, self.y_max = min(y0, y1), max(y0, y1) self.rect_start = None self.draw() if __name__ == "__main__": MandelbrotViewer(1000, 700) plt.show()
For production-grade interactivity (smooth zooming, real-time GPU rendering), use OpenGL, Vulkan, or WebGL-based viewers (e.g., in a web page with WebGL shaders).
6. Advanced features
- Continuous zoom sequences: store camera (x_min/x_max/y_min/y_max) per frame and render frames for an animation (FFmpeg to combine).
- Anti-aliasing: supersample by rendering at 2× or 4× resolution and downsample.
- Periodicity checking: detect bulbs that are inside earlier to stop iteration earlier.
- Distance estimation: compute distance from point to set boundary for high-quality ray-marching and smooth shading.
- Arbitrary-precision arithmetic (mpmath, gmpy) lets you zoom far beyond double precision; required for extreme deep zooms.
7. Example: Full script with optional numba and smooth coloring
# mandelbrot_full.py import numpy as np import matplotlib.pyplot as plt try: from numba import njit, prange numba_available = True except ImportError: numba_available = False def mandelbrot_numpy(width, height, x_min, x_max, y_min, y_max, max_iter): xs = np.linspace(x_min, x_max, width) ys = np.linspace(y_min, y_max, height) X, Y = np.meshgrid(xs, ys) C = X + 1j * Y Z = np.zeros_like(C) iters = np.zeros(C.shape, dtype=np.int32) absZ = np.zeros(C.shape, dtype=np.float64) for n in range(1, max_iter + 1): mask = np.abs(Z) <= 2.0 Z[mask] = Z[mask]*Z[mask] + C[mask] escaped = mask & (np.abs(Z) > 2.0) iters[escaped] = n absZ[escaped] = np.abs(Z[escaped]) return iters, absZ if numba_available: @njit(parallel=True, fastmath=True) def mandelbrot_numba(width, height, x_min, x_max, y_min, y_max, max_iter): out = np.zeros((height, width), np.int32) abs_out = np.zeros((height, width), np.float64) for j in prange(height): y = y_min + (y_max - y_min) * j / (height - 1) for i in range(width): x = x_min + (x_max - x_min) * i / (width - 1) c_re, c_im = x, y z_re, z_im = 0.0, 0.0 iter_count = 0 while z_re*z_re + z_im*z_im <= 4.0 and iter_count < max_iter: tmp = z_re*z_re - z_im*z_im + c_re z_im = 2.0*z_re*z_im + c_im z_re = tmp iter_count += 1 out[j, i] = iter_count abs_out[j, i] = np.sqrt(z_re*z_re + z_im*z_im) return out, abs_out def smooth(iter_counts, absZ, max_iter): h = iter_counts.astype(np.float64) mask = (iter_counts > 0) & (iter_counts < max_iter) h[mask] = h[mask] + 1 - np.log(np.log(absZ[mask])) / np.log(2) h[iter_counts == 0] = max_iter return h if __name__ == "__main__": width, height = 1200, 800 bounds = (-2.5, 1.0, -1.25, 1.25) max_iter = 500 if numba_available: iters, absZ = mandelbrot_numba(width, height, *bounds, max_iter) else: iters, absZ = mandelbrot_numpy(width, height, *bounds, max_iter) img = smooth(iters, absZ, max_iter) plt.figure(figsize=(10, 7)) plt.imshow(img, cmap='twilight', extent=[bounds[0], bounds[1], bounds[2], bounds[3]]) plt.axis('off') plt.title('Mandelbrot Set (smooth)') plt.show()
8. Practical tips and exploration ideas
- Start with modest resolution (800×600) and low iterations (200) to test parameters.
- Increase max_iter gradually when zooming deeper; use arbitrary precision for extremely deep zooms.
- Try different color maps and log/linear scaling to highlight structures.
- Render tiles in parallel and stitch them for very large images.
- Share sequences as animated zooms or interactive viewers using web frameworks.
9. Troubleshooting common issues
- Slow rendering: try numba or GPU, or reduce resolution/iterations.
- Banding in colors: use smooth coloring and continuous colormaps.
- Artifacts at edges: ensure correct aspect ratio and extent in imshow.
- Crashes with deep zooms: use arbitrary-precision arithmetic libraries.
10. Further reading and experiments
- Explore Julia sets (same iteration formula but z_0 = point, c constant).
- Implement distance estimation to create 3D-like shading.
- Study period-doubling, cardioid and bulb structures, and external angles for deeper math.
This guide gives you a practical pipeline from math to a polished Mandelbrot plotter: starting with a clear CPU implementation, moving to performance and coloring improvements, and finishing with interactivity and advanced features.
Leave a Reply