Skip to main content

Pycairo

Pycairo ist ein Python-Modul, das Einbindungen für die Bibliothek cairo bereitstellt und cairo ist eine Grafikibliothek, mit der Vektorgrafiken auf eine Oberfläche gezeichnet werden können. Der Vorteil hierbei ist, dass Vektorgrafiken beliebig hochskaliert werden können, ohne an Details einzubüßen.

In diesem Beitrag wird erklärt, wie man mit cairo in Python (bzw. mit dem Modul Pycairo) Vektorgrafiken erstellt. Nach der Installation und Einführung in cairo werden drei Projekte erstellt, die zum Schluss auch animiert werden.:

Der Projektcode kann hier heruntergeladen werden.

Installation

Das Modul Pycairo lässt sich einfach über die Paketeverwaltung pip installieren:


pip install pycairo

Jedoch greift das Modul auf die entsprechende Bibliothek namens cairo im Betriebssystem zurück, weshalb diese auch installiert werden müssen. Hierfür folgt man am besten der Anleitung, die auf der Webseite von cairo bereitgestellt wird: https://www.cairographics.org/download/. Unter macOS kann neben den Installationsmöglichkeiten, die auf der Webseite von cairo vorgeschlagen werden, die Bibliothek auch mittels Homebrew installiert werden:


brew install cairo

Einführung

Um nun mit cairo in Python arbeiten zu können, muss man das entsprechende Modul importieren, eine Zeichenoberfläche (bzw. Surface auf Englisch) erstellen und sie mit einem Kontext (bzw. Context auf Englisch) verbinden, der die Zeichenbefehle übergeben werden:


import cairo

WIDTH = 300
HEIGHT = 300

surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, WIDTH, HEIGHT)
ctx = cairo.Context(surf)

Das Objekt surf enthält alle Farbdaten, die über das Objekt ctx gezeichnet wurden. Man kann die Oberfläche wie seine Leinwand und den Kontext wie sein Malwerkzeug verstehen. Der Computer muss also angewiesen werden, mit dem Kontext auf der Oberfläche zu zeichnen.

Es ist jedoch anzumerken, dass cairo (wie verschiedene andere Grafikbibliotheken auch) auf das Zeichnen von Pfaden abstellt, um Formen zu bilden. Das bedeutet, es gibt nur eine Hand voll Methoden, mit denen die Pfade für Linien, Rechtecke, Kurven oder Text beschrieben werden können. Das Zeichnen selbst wird in zwei Schritte unterteilt:

  1. das Beschreiben eines zu zeichnenden Pfads über den Kontext; und
  2. das Anwenden des Pfads auf die Oberfläche.

Das bedeutet, dass bei Anweisungen, die Koordinaten und Form des Pfads beschreiben, noch nichts gezeichnet wird und man beim Speichern der Oberfläche ein leeres Bild erhält. Erst nachdem der Pfad selbst mit einer bestimmten Linienstärke abgefahren oder die Fläche, die er umschließt, mit einer Farbe gefüllt wird, erstellt man ein Bild auf der Oberfläche, das auch gespeichert werden kann.

Für das Beschreiben von Pfaden und das Zeichnen, stellt Cairo verschiedene Methoden bereit, die über das Objekt ctx aufgerufen werden.

Bewegen

Manchmal benötigt cairo die Koordinate eines Punktes, von dem aus eine Zeichnung beginnen soll. Um diesen Startpunkt zu bestimmen, benutzt man die folgende Methode:


move_to(x: float, y: float) -> None

Soll der Startpunkt z.B. in die Mitte gesetzt werden, geht das wie folgt:


ctx.move_to(WIDTH / 2, HEIGHT / 2)

Linie

Nachdem ein Startpunkt bestimmt wurde, kann eine Linie mit der folgenden Methode beschrieben werden, die Koordinaten eines Endpunkts als Argumente entgegennimmt:


line_to(x: float, y: float) -> None

Nach jedem Aufruf dieser Methode wird der letzte Endpunkt der neue Startpunkt, sodass der Pfad eines Dreiecks wie folgt beschrieben werden könnte:


ctx.move_to(WIDTH / 2 - 50, HEIGHT / 2 - 50) # Startpunkt unten links
ctx.line_to(WIDTH / 2     , HEIGHT / 2 + 50) # Mittelpunkt oben
ctx.line_to(WIDTH / 2 + 50, HEIGHT / 2 - 50) # Endpunkt unten rechts
ctx.line_to(WIDTH / 2 - 50, HEIGHT / 2 - 50) # Schließen des Dreiecks

Schließen eines Pfades

Anstatt eine Linie zu definieren, die genau auf dem ersten Startpunkt der gesamten Form endet, bietet Cairo eine einfachere Möglichkeit, Pfade zu schließen:


close_path() -> None

Setzt man diese Methode ein, ändert sich der vorstehende Code wie folgt:


ctx.move_to(WIDTH / 2 - 50, HEIGHT / 2 - 50) # Startpunkt unten links (blau)
ctx.line_to(WIDTH / 2     , HEIGHT / 2 + 50) # Mittelpunkt oben (rot)
ctx.line_to(WIDTH / 2 + 50, HEIGHT / 2 - 50) # Endpunkt unten rechts (grün)
ctx.close_path() # Schließen des Dreiecks

Auch wenn noch nichts gezeichnet wurde, kann man sich den Pfad wie folgt vorstellen:

Dreieck

Bogen

Einen Bogen beschreibt man mit der folgenden Methode:


arc(xc: float, yc: float, radius: float, angle1: float, angle2: float) -> None

Hierbei wird ein Mittelpunkt (durch xc und yc) angegeben, von dem aus ein Kreisbogen mit einem gewissen Radius (radius) entgegen dem Uhrzeigersinn von einem Startwinkel (angle1) zu einem Endwinkel (angle2) beschrieben wird. Hierbei ist der Punkt, von dem aus der Bogen begonnen wird, ein Startpunkt, zu dem auch eine Linie von einem früheren Startpunkt gezogen wird, und der Punkt, an dem der Bogen endet, ein neuer Endpunkt, von dem aus der Pfad weiter beschrieben werden kann. Mit dem folgenden Code könnte mann z.B. den Pfad eines Dreiecks beschreiben, bei dem eine Kante jedoch ein Halbkreis ist:


ctx.move_to(WIDTH / 2, HEIGHT / 2 - 75) # Startpunkt unten (blau)
ctx.line_to(WIDTH / 2 + 75, HEIGHT / 2) # Linie (Endpunkt bei rot)
ctx.arc(WIDTH / 2, HEIGHT / 2, 75, 0, pi) # Bogen (Mittelpunkt bei gelb; Endpunkt bei grün)
ctx.close_path() # Schließen des Pfads

Wie oben, kann man sich den Pfad dann wie folgt vorstellen:

Dreieck

Rechteck

Einen Rechteck kann man als einen bereits geschlossenen Pfad mit der folgenden Methode definieren:


rectangle(x: float, y: float, width: float, height: float) -> None

Hierbei geben die Koordinaten x und y jedoch den Punkt der unteren linken Ecke des Rechtecks an, von dem aus das Rechteck eine gewisse Breite (width) und Höhe (height) aufweist. Ein wie folgt definiertes Rechteck:


# Rechteck: Start- und Endpunkt ist blau
ctx.rectangle(WIDTH / 2 - 75, HEIGHT / 2 - 50, 150, 100)

sähe dann (wenn es gezeichnet wird) wie folgt aus:

Rechteck

Hierbei sind Start- und Endpunkt derselbe (also der blaue Punkt im vorstehenden Beispiel).

Kurve

Um eine Kurve zu beschreiben, wird die folgende Methode benutzt:


curve_to(x1: float, y1: float, x2: float, y2: float, x3: float, y3: float) -> None

Die dieser Methode zugrunde liegende Geometrie beruht auf der Mathematik einer Bézierkurve, die durch vier Punkte (Startpunkt, Endpunkt und zwei Steuerpunkte) beschrieben wird. Um eine solche Kurve in Cairo zu beschreiben, muss man also zuerst mit move_to() (oder eine sonstige Methode, die einen Pfad beschreibt) einen Startpunkt definieren, von dem aus dann die Kurve über die drei folgenden Punkte, die der Methode übergeben werden, beschrieben wird. Wie im folgenden Beispiel:


ctx.move_to(WIDTH / 2 - 50, HEIGHT / 2) # Startpunkt (blau)
ctx.curve_to(
    WIDTH / 2 - 25, HEIGHT / 2 + 90, # Steuerpunkt 1 (grün)
    WIDTH / 2 + 25, HEIGHT / 2 - 90, # Steuerpunkt 2 (gelb)
    WIDTH / 2 + 50, HEIGHT / 2 # Endpunkt (rot)
)

Dieses würde dann die folgende Kurve beschreiben.

Kurve

Zeichnen

Alle der vorstehenden Methoden haben lediglich die Geometrie des Pfades durch Linien, Bögen oder Kurven beschrieben. Um den dadurch erhaltenen Pfad zu zeichnen gibt es eine Methode, mit der der Pfad abgefahren wird (auf Englisch stroke) und eine andere Methode, mit der die durch den Pfad beschriebene Fläche gefüllt (auf Englisch fill) wird. Um diese einsetzen zu können, muss man jedoch eine Farbe, Linien- und Flächeneigenschaften definieren:

Farbe

Bevor man jedoch etwas zeichnen kann, muss dem Kontext mitgeteilt werden, wie man die Surface bearbeiten mag. Eine Farbe definiert man mit den folgenden Methoden:


# Der Rot-, Grün- und Blauanteil
set_source_rgb(red: float, green: float, blue: float) -> None

# Der Rot-, Grün- und Blauanteil mit Transparenz
set_source_rgba(red: float, green: float, blue: float, alpha: float) -> None

Hierbei werden alle Werte als Gleitkommazahl zwischen 0 und 1 angegeben.

Alternativ kann auch eine andere Surface abgepaust werden. Das geschieht mit der folgenden Methode:


set_source_surface(surface: cairo.Surface, x: float, y: float) -> None

Hierbei muss man neben der übergebenen Surface auch noch ihre Position (durch x und y) auf der Ziel-Surface (zu der der Kontext gehört, auf dem man die Methode anwendet) angeben.

Linien und Konturen zeichnen

Hat man eine Farbe oder abzupausende Surface angegeben, kann man damit den beschriebenen Pfad zeichnen. Für die dadurch entstehende Linie (oder Kontur) gibt es drei Eigenschaften, die mit den folgenden Methoden gesetzt werden können:


# Die Linienstärke
set_line_width(width: float) -> None

# Die Art, wie Linien enden
set_line_cap(line_cap: cairo.LineCap) -> None

# Die Art, wie Linien verbunden werden
set_line_join(line_join: cairo.LineJoin) -> None

Die Linienstärke ist hierbei die am häufigsten benutzte Methode. Linienkappen und -verbindungen sind nur dann relevant, wenn man keine geschlossenen Pfade durch mehrere Linien definiert und eckige Verbindungen und Enden vermeiden will.

Danach kann die Linie gezeichnet werden. Hierbei kann entweder der definierte Pfad gelöscht werden, oder erhalten (auf Englisch preserve) werden:


# Zeichnet den Pfad
stroke() -> None

# Zeichnet den Pfad und belässt ihn im Speicher
stroke_preserve() -> None

Das Definieren und Zeichnen von Pfaden funktioniert dann z.B. wie folgt:


ctx.move_to(WIDTH / 2, HEIGHT / 2 + 75)
ctx.line_to(WIDTH / 2 + 75, HEIGHT / 2)
ctx.arc_negative(WIDTH / 2, HEIGHT / 2, 75, 0, pi)
ctx.close_path()

ctx.set_source_rgb(0.52, 0.21, 0.19)
ctx.set_line_width(5)
ctx.stroke()

Wodurch dann die folgende, dunkelrote Form einer Kontur auf der Surface gezeichnet wird:

Dreieck

Nur fällt hier beim Vergleich mit den im Code angegeben Koordinaten auf, dass alles auf dem Kopf gezeichnet wurde. Das liegt daran, dass die Y-Achse geschichtsbedingt nicht von unten nach oben verläuft (wie es gewöhnlich der Fall ist), sondern von oben nach unten. Daher ist das gezeichnete Bild immer vertikal spiegelverkehrt.

Um dieses Problem zu beheben, kann man entweder den Y-Wert jeder Koordinate negieren (also mit -1 multiplizieren), oder man spiegelt das fertige Bild vertikal. Letztere Option ist oft die Bevorzugte, da somit weniger Rechenschritte notwendig sind und nicht aus Versehen das negieren einer Koordinaten vergisst.

Will man aus den erstellten Bildern mit ffmpeg ein Video erstellen, kann man beim Kodieren über einen Videofilter das Bild automatisch vertikal spiegeln:
-vf vflip

Füllen

Neben den Linien und Konturen kann man auch die durch den Pfad beschriebene Fläche füllen (auf Englisch fill). Die dafür benutzten Methoden lauten wie folgt:


# Füllt durch Pfad beschriebene Fläche
fill() -> None

# Füllt durch Pfad beschriebene Fläche und
# belässt Pfad im Speicher
fill_preserve() -> None

Wandelt man das letzte Beispiel ab, indem man das Zeichnend er Linie durch fill() ersetzt, erhält man die folgende Code:


ctx.move_to(WIDTH / 2, HEIGHT / 2 + 75)
ctx.line_to(WIDTH / 2 + 75, HEIGHT / 2)
ctx.arc_negative(WIDTH / 2, HEIGHT / 2, 75, 0, pi)
ctx.close_path()

ctx.set_source_rgb(0.52, 0.21, 0.19)
ctx.fill()

Somit wird die folgende dunkelrot gefüllte Form auf der Surface gezeichnet:

Dreieck

Speichern

Hat man nun auf der Surface eine Form gezeichnet, kann man mit der folgenden Methode ein PNG-Bild speichern:


write_to_png(fobj: _PathLike | _FileLike) -> None

Diese Methode wird jedoch nicht mit dem Kontext (ctx) sondern mit der Surface (surf) ausgeführt. Man übergibt hier entweder eine Zeichenkette, die den Pfad definiert, wo das Bild gespeichert werden soll, oder man übergibt ein durch open() erhaltenes Dateiobjekt. Die vorstehenden Beispiele können dann wie folgt als Bild im Arbeitsverzeichnis gespeichert werden:


surf.write_to_png("beispiel.png")

Beispiele

Da nun alle wichtigen Methoden erklärt wurden, die zum Definieren von Pfaden und zum Zeichnen der Pfade bzw. zum Füllen der durch die Pfade beschriebenen Flächen nötig sind, kann die Anwendung von cairo anhand der drei nachfolgenden Beispiele erläutert werden.

Jedes dieser Beispiele befasst sich zuerst damit, ein statisches Bild zu erzeugen. Im zweiten Schritt werden dann mehrere Bilder erstellt, die zusammen die eingangs vorgestellten Animationen ergeben. Auch wenn in den Beispielen die Heranziehung von des Mathematikmoduls numpy die Berechnung vereinfachen würde, so wird hier lediglich auf das Modul math von Python zurückgegriffen.

Farbkreis

Der Farbkreis besteht eigentlich aus drei Kreisen, dessen Farben addiert werden, um einen weißen Kreis zu erzeugen. Denn die Addition von rotem, grünem und blauem Licht, ergibt weißes Licht:

Mathematik

Anstatt einen Bogen als Kreis zu definieren, wird jeder der drei Kreise durch eine gewisse Anzahl von Punkten beschrieben. Angenommen, die Anzahl von Punkten ist $N$ und jeder Punkt wird durch $n$ beschrieben (wobei $ 0 \leq n \lt N $), so können die $x$ und $y$ Koordinate jedes Punktes eines Kreises wie folgt ausdrücken werden:

$$ \begin{align} x(n) &= R \cdot \cos\left(\frac{n}{N} 2 \pi\right) \\ y(n) &= R \cdot \sin\left(\frac{n}{N} 2 \pi\right) \end{align} $$

Hierbei ist $R$ der Radius des Kreises. Schlaufüchse erkennen vielleicht, dass man hier $\frac{n}{N} 2 \pi$ auch als einen von $n$ abhängigen Winkel $\theta_n$ schreiben könnte (wobei $ 0 \leq \theta_n \lt \pi$). Dadurch werden die Gleichungen etwas überschaubarer (und das nachstehende wird nicht durch unnötige Symbolik verkompliziert):

$$ \begin{align} x(n) &= R \cdot \cos\left(\theta_n\right) \\ y(n) &= R \cdot \sin\left(\theta_n\right) \end{align} $$

Um nun den Welleneffekt zu erzeugen, muss der Radius abhängig vom Winkel des Punktes skaliert werden. Den Radius abhängig zu machen, wird erzielt, indem $R$ mit der Funktion $r(\theta)$ skaliert wird. Diese Funktion könnte dann wie folgt definiert werden:

$$ r(\theta) = 1 + 0,1 \cdot \sin(8 \theta) $$

Hierbei wird der Radius mit acht Sinusschwingungen von einem konstanten Wert $1$ auf Werte zwischen $0,9$ und $1,1$ skaliert. Für die acht Schwingungen und die Oszillation von $1 \pm 0,1$ habe ich mich entschieden, da dies durchs Ausprobieren am Ende am besten aussah.

Mit dem Programm Grapher unter macOS kann man einen Polargrafen erstellen und direkt mit $\theta$ verschiedene Skalierungen des Radius ausprobieren.

Grapher

Da die Welle allerdings nicht vollumfänglich sichtbar sein soll, muss sie abhängig vom Winkel $\theta_n$ teilweise auf null redutiert werden, sodass der Radius in diesem Sektor konstant bleibt; also $r(\theta) = 1$ ergibt. Hierfür definiert man sich am einfachsten einen weiteren Skalierungsfaktor $k(\theta)$, der nur den positiven Teil einer Sinusfunktion widerspiegelt. Multipliziert man diesen mit dem Sinusanteil der Welle, so ergibt sich folgende Formel:

$$ r(\theta) = 1 + 0,1 \cdot \sin(8 \theta) k(\theta) \begin{cases} k(\alpha) = \sin(\alpha); & \sin(\alpha) > 0\\ k(\alpha) = 0; & \text{sonst}\\ \end{cases} $$

Dasselbe kann auch mit der $\max$ Funktion verkürzt geschrieben werden:

$$ r(\theta) = 1 + 0,1 \cdot \sin(8 \theta) \max\left[0, sin(\theta)\right] $$

Das Ergebnis sieht dann in Grapher wie folgt aus:

Grapher

Zuletzt muss das vorstehende noch für jeden der drei Kreise (also einen roten, grünen und blauen) angewandt werden, wobei die Welle jedes Kreises um $120\deg$ verschoben wird (bzw. um $\frac{2}{3}\pi$), sodass sie sich nicht immer überlappen. Nimmt man die Variable $k$ mit in die Skalierungsfunktion $r$ mit auf, sodass durch $k$ angegeben wird, welcher der drei Kreise skaliert wird (wobei $ k \in \{0, 1, 2\} $), kann $r$ wie folgt um eine Phasenverschiebung der Welle ergänzt werden:

$$ r(\theta, k) = 1 + 0,1 \cdot \sin\left(8 \theta + k\frac{2}{3}\pi\right) \max\left[0, sin(\theta)\right] $$

Vernachlässigt man die Skalierung mit der $\max$ Funktion, würde das in Grapher das folgende Bild ergeben:

Grapher

Und wendet man nun die Skalierung an, ergibt sich das folgende Bild:

Grapher

Die letzte Ergänzung zur Skalierungsfunktion $r$ ist die Variable $t$, die einen Zeiterlauf von null bis eins angibt. Die Funktion $r$ sieht dann wie folgt aus:

$$ r(\theta, k, t) = 1 + 0,1 \cdot \sin\left(8 \theta - 2 \pi t + k\frac{2}{3}\pi\right) \max\left[0, sin(\theta)\right] $$

Animiert man nun mit Grapher die Zeit von $t = 0$ bis $t = 1$ und vernachlässigt man wieder die Skalierung, würde sich die folgende Animation ergeben:

Und wendet man nun die Skalierung an, ergibt sich die folgende Animation:

Um die Skalierung der Welle auch von der Zeit $t$ abhängig zu machen, muss $2 \pi t$ auch innerhalb der $\max$ Funktion angewandt werden. Als Ergebnis wirken die Kreise, als ob sie sich drehen. Um die Richtung der Skalierung entgegen der Richtung der Welle laufen zu lassen, wird $2 \pi t$ bei der $\max$ Funktion aber nicht subtrahiert, sondern addiert:

$$ r(\theta, k, t) = 1 + 0,1 \cdot \sin\left(8 \theta - 2 \pi t + k\frac{2}{3}\pi\right) \max\left[0, sin(\theta + 2 \pi t)\right] $$

Das animierte Ergebnis sieht dann wie folgt aus:

Ersetzt man nun $\theta_n$ in $r(\theta, k, t)$ und ergänzt die Definitionen der $x$ und $y$ Koordinaten hierum, so ergibt sich die folgenden Gleichungen:

$$ \begin{align} x(n, k, t) &= R \cdot r\left(\frac{n}{N}2 \pi, k, t\right) \cdot \cos\left(\frac{n}{N} 2 \pi\right) \\ y(n, k, t) &= R \cdot r\left(\frac{n}{N}2 \pi, k, t\right) \cdot \sin\left(\frac{n}{N} 2 \pi\right) \end{align} $$

Und ersetzt man nun $r(\theta, k, t)$ mit dessen Definition, ergeben sich letzten Endes die folgenden Gleichungen, von denen ausgehen dann der Code geschrieben werden kann:

$$ \begin{align} x(n, k, t) &= R \cdot \left\{ 1 + 0,1 \cdot \sin\left(8 \theta - 2 \pi t + k\frac{2}{3}\pi\right) \max\left[0, sin(\theta + 2 \pi t)\right] \right\} \cdot \cos\left(\frac{n}{N} 2 \pi\right) \\ y(n, k, t) &= R \cdot \left\{ 1 + 0,1 \cdot \sin\left(8 \theta - 2 \pi t + k\frac{2}{3}\pi\right) \max\left[0, sin(\theta + 2 \pi t)\right] \right\} \cdot \sin\left(\frac{n}{N} 2 \pi\right) \end{align} $$

Programmieren

Fangen wir mit den Grundlagen an, und erstellen ein Programm, mit dem schonmal ein leeres Bild erzeugt wird, sodass darauf dann die Kreise gezeichnet werden können:


import cairo

# Assuming 60 fps
FRAME_COUNT = 240

def new(background_color: tuple[float] = None) -> tuple[cairo.Surface, cairo.Context]:
    """
    Returns a tuple of (`Surface`, `Context`) with a certain background color.
    """
    
    surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, WIDTH, HEIGHT)
    ctx = cairo.Context(surf)

    if background_color is not None:
        ctx.rectangle(0, 0, WIDTH, HEIGHT)
        ctx.set_source_rgba(*background_color)
        ctx.fill()

    return surf, ctx

def main():
    for n in range(FRAME_COUNT):
        # Get a new render surface
        render_surf, render_ctx = new((0, 0, 0))
        
        # Drawing code goes here...

        render_surf.write_to_png(f"frame-{n}.png")

if __name__ == "__main__":
    main()