Zusatzbeispiel im Stil des Buchs

Die Mandelbrot-Menge in Python

Eine einzige Formel, immer wieder angewendet – und heraus kommt eines der berühmtesten Bilder der Mathematik. Klick ins Bild, und du tauchst in immer neue Spiralen, Seepferdchen und Mini-Kopien der ganzen Menge ein.

Eigenes Beispiel, nicht aus dem Buch Fortgeschritten Mathe & FraktaleGrafik & Animation Python 3 · läuft im Browser
Bereit · Python startet beim ersten Klick
# Mandelbrot-Menge – Zusatzbeispiel zu Kapitel 11 »SOS – Save our Screens«
# Reines Python mit komplexen Zahlen. Klick ins Bild: hineinzoomen.
import math
import time
from c4f import Screen, hsv, MOUSEDOWN, KEYDOWN

WIDTH, HEIGHT = 640, 400
START_CENTER  = complex(-0.65, 0.0)
START_WIDTH   = 3.2          # so breit ist der Ausschnitt in der komplexen Ebene
ZOOM_STEP     = 3            # Faktor pro Klick


def mandelbrot(c, max_iter):
    """Wie viele Schritte, bis z = z*z + c den Kreis mit Radius 2 verlässt?"""
    z = 0
    for n in range(max_iter):
        z = z * z + c
        if abs(z) > 2:
            return n
    return max_iter          # bleibt drin: gehört (vermutlich) zur Menge


def make_palette(max_iter):
    """Eine Farbe pro Schrittzahl – vorher berechnet, das spart Zeit."""
    palette = []
    for n in range(max_iter):
        t = n / max_iter
        r, g, b = hsv(0.66 + 3 * t, 0.8, min(1.0, 4 * t + 0.15))
        palette.append(bytes((r, g, b, 255)))
    palette.append(bytes((0, 0, 0, 255)))  # Punkte der Menge: schwarz
    return palette


def to_complex(px, py, center, width):
    step = width / WIDTH
    return complex(center.real + (px - WIDTH / 2) * step,
                   center.imag + (py - HEIGHT / 2) * step)


async def render(center, width):
    """Berechnet das Bild Zeile für Zeile. Gibt ein Ereignis zurück, falls
    während des Rechnens geklickt wurde – dann fängt es von vorn an."""
    zoom = 3.2 / width                    # Vergrößerung gegenüber dem Gesamtbild
    max_iter = int(60 + 30 * math.log(max(zoom, 1), 3))
    palette = make_palette(max_iter)
    step = width / WIDTH
    x0 = center.real - WIDTH / 2 * step
    start = time.time()
    for py in range(HEIGHT):
        ci = center.imag + (py - HEIGHT / 2) * step
        row = bytearray()
        for px in range(WIDTH):
            row += palette[mandelbrot(complex(x0 + px * step, ci), max_iter)]
        screen.pixels(0, py, WIDTH, 1, row)
        if py % 10 == 9:
            await screen.frame(None)    # anzeigen, was schon fertig ist
            for event in screen.events():
                if event.type in (MOUSEDOWN, KEYDOWN):
                    return event
    print(f"Zoom {zoom:,.0f}×  Mitte {center.real:+.6f} {center.imag:+.6f}i  "
          f"{max_iter} Schritte  {time.time() - start:.1f} s")
    return None


screen = Screen(WIDTH, HEIGHT, background="black", title="Mandelbrot")
screen.controls([("r", "Zurück zum Start"), ("-", "Herauszoomen")])
center, width = START_CENTER, START_WIDTH
event = await render(center, width)

while True:
    events = [event] if event else screen.events()
    event = None
    for e in events:
        if e.type == MOUSEDOWN and e.button == 1:
            center = to_complex(e.x, e.y, center, width)
            width /= ZOOM_STEP
        elif (e.type == MOUSEDOWN and e.button == 3) or (e.type == KEYDOWN and e.key == "-"):
            width = min(START_WIDTH, width * ZOOM_STEP)
        elif e.type == KEYDOWN and e.key == "r":
            center, width = START_CENTER, START_WIDTH
        else:
            continue
        event = await render(center, width)
        break
    else:
        await screen.frame(30)
Farbige Mandelbrot-Menge: schwarze Apfelmännchen-Form mit leuchtenden blauen und violetten Rändern

Klick oder Tipp ins Bild zoomt hinein, Rechtsklick oder »Herauszoomen« wieder heraus.

Konsole

    

Was du hier lernst

  • Komplexe Zahlen in Python
  • Iteration und Abbruchbedingung
  • Fluchtzeit-Algorithmus
  • Farbpaletten
  • Pixel mit bytearray
  • Maus-Ereignisse

So entsteht das Apfelmännchen

Das Bild zeigt keine gezeichnete Form, sondern das Ergebnis einer Rechnung – für jeden einzelnen Pixel. Jeder Pixel steht für eine Zahl c. Mit dieser Zahl spielt das Programm immer wieder dieselbe Formel durch und schaut, was passiert. Aus der Antwort wird eine Farbe.

1. Eine Formel, immer wieder

Der Kern ist die Funktion mandelbrot. Sie beginnt mit z = 0 und rechnet dann wiederholt z = z * z + c:

def mandelbrot(c, max_iter):
    z = 0
    for n in range(max_iter):
        z = z * z + c
        if abs(z) > 2:
            return n
    return max_iter

Für manche c bleibt z für immer klein, für andere wächst es explosionsartig. abs(z) > 2 erkennt die Flucht, und n sagt, nach wie vielen Schritten sie passiert ist. Diese Schrittzahl heißt Fluchtzeit. Punkte, die nach max_iter Schritten noch nicht geflohen sind, zählen zur Mandelbrot-Menge.

Warum gerade 2?

Sobald der Betrag von z größer als 2 ist, wächst er mit jedem weiteren Schritt garantiert weiter – der Punkt kommt nie zurück. Man muss also nicht ewig weiterrechnen: Der Kreis mit Radius 2 ist die Grenze, ab der die Entscheidung feststeht.

2. Komplexe Zahlen sind in Python eingebaut

c und z sind komplexe Zahlen: Zahlen mit einem Realteil und einem Imaginärteil, die man sich als Punkt in einer Ebene vorstellen kann. Viele Sprachen brauchen dafür eine Bibliothek, Python kann es von Haus aus:

>>> c = complex(-0.5, 0.5)   # oder: -0.5 + 0.5j
>>> c * c
-0.5j
>>> abs(3 + 4j)
5.0

Deshalb sieht die Formel im Code genauso aus wie in einem Mathebuch. abs(z) ist der Abstand vom Nullpunkt.

3. Vom Pixel zur komplexen Zahl

Die Leinwand hat 640 × 400 Pixel, der interessante Teil der Ebene ist aber nur etwa 3,2 Einheiten breit. step ist die Breite eines Pixels in der Ebene; die Bildmitte entspricht center:

step = width / WIDTH
x0 = center.real - WIDTH / 2 * step
...
complex(x0 + px * step, ci)

Zoomen heißt nur: width wird kleiner. Die gleichen 640 Pixel decken dann einen winzigeren Ausschnitt ab – und zeigen mehr Details.

4. Farben aus Schrittzahlen

Für jede mögliche Schrittzahl legt make_palette vorab eine Farbe fest, als vier Bytes für Rot, Grün, Blau und Deckkraft. hsv() aus c4f liefert Regenbogenfarben: Der Farbton läuft mit der Schrittzahl mehrmals durch das Spektrum, die Helligkeit steigt. Die Punkte der Menge selbst werden schwarz.

Die Palette vorher zu berechnen spart viel Zeit: Beim Zeichnen ist jede Farbe nur noch ein Nachschlagen in einer Liste – bei 256 000 Pixeln macht das einen großen Unterschied.

5. Zeile für Zeile auf die Leinwand

Jede Bildzeile wird als bytearray gesammelt und mit einem einzigen Aufruf gezeichnet:

row = bytearray()
for px in range(WIDTH):
    row += palette[mandelbrot(complex(x0 + px * step, ci), max_iter)]
screen.pixels(0, py, WIDTH, 1, row)

Alle zehn Zeilen gibt das Programm dem Browser mit await screen.frame(None) kurz die Kontrolle. So siehst du, wie das Bild von oben nach unten wächst, und ein Klick wird sofort bemerkt – dann beginnt die Rechnung mit dem neuen Ausschnitt von vorn.

6. Hineinzoomen

Ein Klick macht den angeklickten Punkt zur neuen Bildmitte und teilt die Breite durch drei. Je tiefer du zoomst, desto mehr Schritte braucht es, um Innen und Außen zu unterscheiden. Deshalb steigt max_iter mit dem Zoom:

zoom = 3.2 / width
max_iter = int(60 + 30 * math.log(max(zoom, 1), 3))

In der Konsole steht nach jedem Bild, wie stark vergrößert ist, wo die Mitte liegt und wie lange die Rechnung gedauert hat.

Verwandt: die Julia-Menge

Im Buch berechnet Kapitel 11 eine Julia-Menge. Der Unterschied ist klein: Dort bleibt c fest, und der Pixel bestimmt den Startwert von z. Unter »Probier mal« kannst du dieses Programm mit einer Zeile in eine Julia-Menge verwandeln.

Probier mal

Jede Variante ändert den Code oben. Ein Klick auf »Ausprobieren« übernimmt die Änderung in den Editor und startet das Programm. »Zurücksetzen« holt das Original zurück.

  1. 01Direkt ins Seepferdchental

    Zwischen dem großen »Kopf« und dem »Rumpf« der Menge liegt ein Tal voller Spiralen, die an Seepferdchen erinnern. Der neue Startpunkt fliegt direkt hin – rund 270-fach vergrößert. Die Zahl der Schritte wächst mit dem Zoom automatisch mit – deshalb braucht dieses Bild einige Sekunden.

    - START_CENTER  = complex(-0.65, 0.0)
    + START_CENTER  = complex(-0.7436, 0.1318)
    - START_WIDTH   = 3.2 
    + START_WIDTH   = 0.012
  2. 02Feuer statt Eis

    Die Farben entstehen in make_palette aus dem Farbton-Modell HSV. Ein kleiner Farbtonbereich um Rot und Gelb, dafür steigende Helligkeit, ergibt ein glühendes Fraktal.

    - r, g, b = hsv(0.66 + 3 * t, 0.8, min(1.0, 4 * t + 0.15))
    + r, g, b = hsv(0.0 + 0.16 * t, 1.0 - 0.6 * t, min(1.0, 3 * t))
  3. 03Aus Mandelbrot wird Julia

    Vertausche die Rollen: Starte nicht bei z = 0 mit dem Pixel als c, sondern beim Pixel als z – und halte c fest. Genau so entsteht eine Julia-Menge. Jedes c ergibt eine andere; -0.8 + 0.156j ist eine besonders schöne.

    -     z = 0
    +     z, c = c, complex(-0.8, 0.156)
    - START_CENTER  = complex(-0.65, 0.0)
    + START_CENTER  = complex(0.0, 0.0)
  4. 04Mehr Rechenschritte, schärfere Ränder

    Mit mehr Schritten pro Pixel werden die feinen Fäden am Rand der Menge sichtbar – das Bild braucht dafür länger. Beobachte in der Konsole, wie sich die Rechenzeit verändert.

    - max_iter = int(60 + 30 * math.log(max(zoom, 1), 3))
    + max_iter = int(250 + 60 * math.log(max(zoom, 1), 3))
  5. 05Zehnfach-Zoom

    Jeder Klick vergrößert jetzt zehnfach statt dreifach. Nach sechs Klicks bist du bei einer Million. Nach etwa 13 Klicks stößt du an die Grenzen der Gleitkommazahlen: Das Bild wird pixelig, weil benachbarte Pixel dieselbe Zahl bekommen.

    - ZOOM_STEP     = 3 
    + ZOOM_STEP     = 10

Hinter den Kulissen

Dieses Beispiel steht so nicht im Buch. Dort geht es in Kapitel 11 unter »Was ist Chaos?« um Fraktale, berechnet wird eine Julia-Menge. In den Quelltexten zum Buch lag außerdem eine sehr schnelle, aber schwer lesbare Mandelbrot-Version mit der Bibliothek NumPy. Das Programm hier ist neu geschrieben, im Stil des Buchs: reines Python, kurze Funktionen, sprechende Namen.

Warum ohne NumPy? Pyodide könnte NumPy laden, das wären aber mehrere Megabyte zusätzlicher Download. Und die Schleife in reinem Python zeigt viel deutlicher, was eigentlich passiert. Der Preis ist Geschwindigkeit: 256 000 Pixel mit jeweils bis zu 60 Schritten sind einige Millionen Rechenoperationen. Im Browser dauert ein Bild deshalb ein bis zwei Sekunden, tiefe Zooms länger.

Warum Zeile für Zeile? Ein Browser zeigt nichts an, solange ein Programm ununterbrochen rechnet. Das await screen.frame(None) nach jeweils zehn Zeilen macht die Rechnung sichtbar – und hält die Seite bedienbar. Python läuft dabei in einem eigenen Hintergrund-Thread (einem Web Worker), sodass du auch während einer langen Rechnung scrollen kannst.

Und die Genauigkeit? Python rechnet mit 64-Bit-Gleitkommazahlen, etwa 16 Stellen genau. Das reicht für Vergrößerungen bis etwa 10¹³. Danach bekommen benachbarte Pixel dieselbe Zahl, und das Bild zerfällt in Blöcke. Für tiefere Zooms bräuchte man Zahlen mit beliebiger Genauigkeit, zum Beispiel aus dem Modul decimal – dann dauert jedes Bild allerdings sehr viel länger.