2025, Sep 14 07:46

Fix Mandelbrot coordinate mapping in Python PIL: center the complex origin to stop mirrored quadrants

Fix Mandelbrot set rendering in Python with PIL by aligning image and complex-plane origins. Solve mirrored quadrants with correct coordinate mapping, offsets

When drawing the Mandelbrot set in Python with PIL, a common first symptom of a coordinate-mapping bug is a picture that looks cut into four parts, with each quadrant mirrored or flipped. The math is fine, the colors are fine—the problem is that the complex plane and the image buffer don’t agree on where the origin lives.

Code example

from PIL import Image
from PIL import ImageShow
from PIL import ImageColor as ColorMap
palette = ["navy","darkblue","blue","cornflowerblue","lightsteelblue","lightskyblue","turquoise","palegreen","lawngreen"]
def iterate_point(c, scale):
    z = 0.0
    iter_count = 0
    iter_cap = 5*scale
    while z.real == z.real and iter_count < iter_cap:
        z = (z*z) + c
        iter_count = iter_count + 1
    if z.real == z.real:
        return -1
    else:
        return iter_count
def run_app():
    scale = int(input("Set Zoom Level. (Default: 10)     ") or "10")*10
    cx = int(input("Input centre x value. (Default: -50)     ") or "-50")
    cy = int(input("Input centre y value. (Default: 0)     ") or "0")
    w2 = canvas.width // 2
    h2 = canvas.height // 2
    for py in range((cy - h2),(cy + h2)):
        for px in range((cx - w2),(cx + w2)):
            c = complex((px/scale),(py/scale))
            steps = iterate_point(c,scale)
            if steps == -1:
                rgb = (0,0,0)
            else:
                rgb = ColorMap.getrgb(palette[steps % len(palette)])
            canvas.putpixel(((px + w2 - cx) % canvas.width,
                             (py + h2 - cy) % canvas.height),
                            rgb)
        print("line",(py+h2),"done")
    ImageShow.show(canvas)
    run_app()
canvas = Image.new("RGB",(1000,500),"Black")
run_app()

The heart of the issue

The function that decides the color for each complex point works with the Mandelbrot set centered at complex(0, 0). That’s how the math is commonly defined: the origin is the center of the complex plane, X is the real axis, and the imaginary axis is vertical. An image buffer, however, treats its origin as the top-left pixel. If you translate complex coordinates directly into pixel indices without reconciling those two coordinate systems, the resulting picture looks like it’s split and mirrored—each quadrant ends up mapped into the wrong part of the canvas.

There’s a second layer: you might also want to shift the view via user inputs for the center. That offset must be applied consistently both when you traverse the complex plane and when you write pixels back to the image, otherwise you’ll see the rendering shifted or wrapped.

The fix

The solution is to align the mathematical center with the visual center. Move the pixel-space origin from the top-left to the image midpoint by using half the width and height. Then account for the user-specified center offset when iterating and when writing to the canvas. In practice this means sweeping over ranges centered on the chosen offsets, converting those loop variables to complex coordinates via the current zoom, and writing pixels back at positions translated by half the image size minus that same offset. The code above does precisely that by computing half-width and half-height from the actual image size, deriving the complex coordinate with the scale factor, and placing pixels using those corrected indices.

While at it, two pragmatic tweaks help the program behave more predictably. Inputs actually honor defaults by using the input value if present or a string default that can be safely converted to int. And image size is no longer hardcoded in the logic, since the half dimensions are computed from the canvas, which makes it easy to change the resolution elsewhere without breaking coordinate math.

There’s also a small robustness improvement inside the iteration. Instead of parsing strings to detect invalid values, the code relies on a simple property: NaN is not equal to itself. Checking z.real == z.real is a compact way to continue while the value is still a proper number and stop once it isn’t, avoiding unnecessary string conversions.

Why this matters

Every algorithm that maps a mathematical domain into pixels lives or dies by its coordinate transform. The Mandelbrot set is especially sensitive because symmetry makes mistakes obvious: a shifted or mirrored origin instantly fractures the familiar cardioid and bulb structure. Getting the center right, applying offsets consistently, and basing iteration windows on the actual image size are small steps that prevent confusing artifacts and make further work—like navigation controls or palette tweaks—much smoother.

On the complex plane, the X axis represents real numbers and the vertical axis represents imaginary numbers. Thinking this way helps keep the mapping straight when you pan or zoom around the set.

Wrap-up

If your Mandelbrot output looks like mirrored quadrants, align the coordinate systems. Center the complex origin on the image center, subtract any user-specified offsets consistently, and compute bounds from the image dimensions instead of hardcoding them. Make input defaults real defaults, and prefer direct numerical checks for NaN. With those pieces in place, the fractal appears as intended, and you have a clean base to iterate on. As a side benefit, when you don’t need to manage vertical offsets, the set’s vertical symmetry can be used to cut work by computing only half the lines. The crucial part is getting the mapping right; everything else becomes straightforward once that’s stable.

The article is based on a question from StackOverflow by Jessica and an answer by bruno.