Erstellen einer Animation mittels Fouriertransformation (German)

Alexander Schoch,mathpythongerman

Wenn du dies liest, hast du vielleicht schon ein paar Beispiele für diese sehr hübschen Animationen gesehen:

In diesem Blogpost werden wir uns ansehen, wie sie funktionieren und wie man eine solche Animation schreibt. Konkret werden wir

Wir werden uns nicht ansehen,

Daher gehe ich davon aus, dass du

ℹ️

Du findest den Quelltext am Ende dieser Seite oder auf meinem GitLab (für alle Bilder).

Die Fouriertransformation

💡

Wenn du dich nicht für die Theorie interessierst, kannst du direkt zum praktischen Teil springen. Die Theorie ist für den Code nicht wichtig, aber es ist immer schön zu verstehen, was man gerade tut :)

Betrachten wir eine beliebige Funktion, in diesem Fall eine einfache step function:

f(t)={1falls tπ1ansonstenf(t) = \begin{cases} 1 &\text{falls } t \leq \pi \\ -1 &\text{ansonsten} \end{cases}

step function

Die Fourier-Reihe besagt, dass jede periodische Funktion als eine unendliche Summe bestimmter Sinuswellen ausgedrückt werden kann (periodisch —> wir loopen diese Funktion einfach nach 2π2\pi. Problem gelöst). Kosinuswellen sind einfach verschobene Sinuswellen: cosx=sin(x+π/2)\cos x = \sin(x + \pi/2). Je mehr Funktionen wir hinzufügen, desto besser wird unsere Annäherung im Allgemeinen sein.

Die folgenden drei Abbildungen zeigen drei Fourierreihen der step function mit 1, 3 und 30 Summanden. Die dünnen Linien stellen die Wellen dar, aus denen sich eine Fourierreihe zusammensetzt. Es ist zu erkennen, dass die Fourierreihe die ursprüngliche Funktion nach einigen Iterationen sehr gut beschreiben kann.(Quellcode)

Fourierreihe mit einem Summanden

Fourierreihe mit drei Summanden

Fourierreihe mit 30 Summanden

Die Aufgabe der Fourier-Transformation besteht nun darin, die spezifischen Wellenfunktionen zu ermitteln, die benötigt werden, um eine beliebige Funktion so gut wie möglich zu konstruieren. Dazu gehören drei Grössen:

Sinusfunktion: Frequenz, Amplitude und Phase

Diskretisierung

Im Gegensatz zu den oben gezeigten Beispielen wird das Input-Bild nicht als glatte Kurven, sondern als eine Reihe von Punkten auf einer 2D-Ebene definiert. Aus diesem Grund werden wir eine Version der Fourier-Transformation verwenden, die speziell für diese Art von Daten entwickelt wurde, die Diskrete Fourier-Transformation.

Die DFT (Quelle: Wikipedia) ist definiert als

Xk=n=0N1xnexp{i2πkNn},X_k = \sum_{n = 0}^{N-1} x_n \cdot \exp\left\{ -i2\pi\frac{k}{N}n \right\},

wobei xnx_n ein punkt auf dem Input-Bild, XkX_k die Fouriertransformation am index kk und NN die Anzahl Input-Punkte darstellt.

Diese Formel zeigt uns aber bereits ein weiteres Problem: Unser Input wird zweidimensional sein und eine xx- und eine yy-Koordinate enthalten.

Von 1D zu 2D

Für die Fourier-Transformation ist der einfachste Weg, mit zweidimensionalen Zahlen umzugehen, die Verwendung komplexer Zahlen (es wird nicht zu schwierig, versprochen!).

Die Eulersche Formel besagt, dass jede imaginäre Exponentialfunktion in oszillierende reelle und imaginäre Teile aufgeteilt werden kann:

eit=cos(t)+isin(t).e^{it} = \cos(t) + i \sin(t).

Mit dieser Eigenschaft ist sichtbar, dass die komplexe Exponentialfunktion eine Oszillation in zwei Dimensionen beschreibt.(source code)

Animation der Eulerischen Formel

Wenn du dir die Animation ganz oben auf dieser Seite ansiehst, siehst du viele Pfeile, die sich mit konstanter Geschwindigkeit im Kreis drehen. Dies ist dasselbe wie die Animation oben, aber wir betrachten den Pfeil in tt-Richtung. Stell dir vor, du stehst auf der Real\text{Real}-Achsenbeschriftung und betrachtest den Pfeil von vorne. Dieser rotierende Pfeil ist unsere zweidimensionale Wellenfunktion. Gemäss der Fourier-Reihe entspricht die Summe vieler dieser rotierenden Pfeile oder Wellenfunktionen fast unendlich gut unserer Input-Funktion. In unserer endgültigen Animation lassen wir also die Zeitdimension weg, um diese rotierenden Pfeile zu erhalten.

Un nun, alles zusammen

Ähnlich wie bei den Fourierreihen-Beispielen zu Beginn dieses Beitrags können wir die Frequenzen (wie schnell sich ein Pfeil dreht), seine Amplitude (wie gross ein Pfeil ist) und seine Phase (bei welchem Winkel der Pfeil beginnt) berechnen, indem wir die DFT auf eine Input-Menge anwenden. Die “Summe der Wellen”, die wir vorhin gesehen hatten, wird auf 2D ausgedehnt, indem die einzelnen Pfeile addiert werden (d. h. sie werden hintereinander gezeichnet, von Spitze zu Ende). Wenn wir dies für jeden Punkt in der Zeit tun, erhalten wir eine Animation einer “Zeichenmaschine”!

Input-Daten

In diesem Beispiel verwende ich das Logo von meiner Universität als Input:

ETH Zürich Logo

Wenn du meine Eingabedaten verwenden möchtest, kannst du meine bereits diskretisierte Input-Datei herunterladen und mit Vorbereitung der Daten fortfahren.

Der erste Schritt besteht darin, diese SVG-Datei in eine Serie von etwa 1000 Punkten zu konvertieren, die wir für die weitere Verarbeitung verwenden können (alles zwischen 100 und 5000 ist tiptop). Ein sehr praktisches Programm für diese Art von Aufgabe ist Inkscape.

Inkscape Installieren

Inkscape kann von ihrer Website oder über deinen App Store oder Package Manager heruntergeladen werden.

Die ExportXY-Erweiterung installieren

Lade die Dateien ExportXY.inx und ExportXY.py herunter und speichere sie in deinem Inkscape-Erweiterungsordner. Ich schreibe diesen Beitrag auf Fedora Linux, daher kann ich die Pfade für Windows und Mac nicht verifizieren.

C:\Users\username\AppData\Roaming\Inkscape\Extensions

Input-Datei in Inkscape öffnen

Öffne deine Datei und entferne alles, was später nicht verwendet werden soll. Deine .svg Datei sollte jetzt nur noch ein Objekt enthalten, das als ein Pfad gezeichnet werden kann.

Objekt in einen Pfad umwandeln

Wähle dein Objekt aus und wandle es in einen Pfad um, indem du “Umschalt + Strg + C” drückst. Du weisst, dass es funktioniert hat, wenn du auf das “Node Tool” auf der linken Seite klickst und die Nodes deines Pfades als graue Rauten siehst.

Inkscape Node Tool

Weitere Nodes hinzufügen

Klicke nun auf “Extensions” > “Modity Path” > “Add Nodes” und füge jedem Segment Punkte hinzu, sodass dein Ergebnis etwa 1000 Punkte enthält.

Inkscape Add Nodes Inkscape Add Nodes Dialog

Dein Pfad sollte nun etwa so aussehen (Node Tool):

Inkscape with added nodes

Export

Klicke auf “Extensions” > “Export” > “Export XY” und kopiere den Inhalt in eine neue Datei input.dat.

Inkscape XY Export

Vorbereitung der Daten

Zuerst richten wir die Python-Environment ein. Dazu erstellen wir eine virtuelle Umgebung, aktivieren sie und installieren die Packages numpy, pandas, matplotlib, pillow und alive_progress.

python -m venv venv
source venv/bin/activate
 
pip install numpy pandas matplotlib pillow alive_progress

Erstelle nun ein neues Python-Skript und öffne es in einem beliebigen Texteditor oder einer IDE deiner Wahl. Importiere die zuvor installierten Packages.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
 
from PIL import Image, ImageDraw
from alive_progress import alive_bar

Als nächstes laden wir die Daten, die wir in Inkscape erzeugt haben. Da diese Daten TAB-separiert sind, weisen wir Pandas an, \t als Trennzeichen zu verwenden und die erste Zeile nicht als header für die Spalten zu verwenden. Anschliessend werden alle eingegebenen Datenpunkte in komplexe Zahlen umgewandelt (x-Wert = Realteil, y-Wert = Imaginärteil). Die Konvertierung in ein numpy-Array ermöglicht es uns, Real- und Imaginärteil des gesamten Arrays mit z.B. input_complex.real zu erhalten.

# Input Data
input = pd.read_csv('input.dat', delimiter='\t', header=None)
input_complex = np.array([complex(input[0][i], input[1][i]) for i in range(len(input))])

Das Problem mit diesen Rohdaten ist, dass das Koordinatensystem für uns immer noch arbiträr ist. Die Koordinaten der einzelnen Punkte sind durch die ursprüngliche SVG-Datei definiert, und wir müssen die Eingabedaten transformieren (im Sinne von “verschieben und skalieren”, nicht Fourier-Transformation), damit sie in unseren zukünftigen canvas passen.

Definieren wir zunächst die Grösse des canvas als komplexe Zahl (in Pixeln). Mein Bild ist sehr querformatig, daher verwende ich einen breiten canvas. Definiere eine linke obere Ecke und eine rechte untere Ecke, zwischen denen das Bild gezeichnet werden soll. Hier lege ich fest, dass 75% der Canvasbreite für das Bild verwendet werden sollen. Beachte, dass die obere linke Ecke negativ ist (hier: 300150i-300-150i). Der Grund dafür ist, dass die Fourier-Animation bei (0, 0) beginnt, und es sieht besser aus, wenn dieser Punkt in der Mitte des Bildes liegt als oben links. center, target_width und target_height sind nützliche Konstanten, die wir später verwenden werden.

# Constants
CANVAS_SIZE = 800 + 400j
 
TOP_LEFT = -CANVAS_SIZE / 8 * 3
BOTTOM_RIGHT = CANVAS_SIZE / 8 * 3
 
center = (TOP_LEFT + BOTTOM_RIGHT) / 2
target_width = BOTTOM_RIGHT.real - TOP_LEFT.real
target_height = BOTTOM_RIGHT.imag - TOP_LEFT.imag

Als Nächstes berechnen wir die Ausdehnung unseres Originalbildes, indem wir die Minimal- und Maximalwerte des Real- und Imaginärteils nehmen. Vertikale Werte werden wieder in komplexe Zahlen umgewandelt, sodass das Hinzufügen von z.B. topmost_point nur einen Wert auf der yy-Achse ergibt.

Da die Eingabedaten nicht notwendigerweise das gleiche Seitenverhältnis haben wie die mit TOP_LEFT und BOTTOM_RIGHT eingeschlossene Bounding Box, und wir die Breite des Bildes an diese Bounding Box anpassen werden, berechnen wir einen Parameter margin_top, um das Bild vertikal zu zentrieren, ohne es entlang der yy-Achse zu strecken.

leftmost_point = min(input_complex.real)
rightmost_point = max(input_complex.real)
topmost_point = complex(0, min(input_complex.imag))
bottommost_point = complex(0, max(input_complex.imag))
 
width = rightmost_point - leftmost_point
height = bottommost_point - topmost_point
margin_top = (target_height - height.imag / width * target_width) / 2

Jetzt transformieren wir das Originalbild in die richtigen Dimensionen. Zuerst subtrahieren wir leftmost_point und topmost_point, sodass die obere linke Ecke des Bildes bei (0,0) liegt. Dann dividieren wir durch die width des Bildes und multiplizieren mit der target_width, sodass unser Bild eine Breite von 600 Pixeln hat. Dann fügen wir TOP_LEFT hinzu, um das Bild horizontal zu zentrieren, und addieren dann margin_top, um das Bild auch vertikal zu zentrieren.

input_transformed = (input_complex - leftmost_point - topmost_point) / width * target_width + TOP_LEFT + complex(0, margin_top)

Um zu testen, ob dies korrekt funktioniert hat, plotten wir den transformierten Input. Der Grund für die umgekehrten Minuszeichen in plt.ylim ist, dass das Koordinatensystem von matplotlib eine umgekehrte yy-Achse (positiv = oben) im Vergleich zu unseren Eingabedaten (positiv = unten) hat.

plt.plot(input_transformed.real, input_transformed.imag, color='black')
plt.xlim(-CANVAS_SIZE.real / 2, CANVAS_SIZE.real / 2)
plt.ylim(CANVAS_SIZE.imag / 2, -CANVAS_SIZE.imag / 2)
plt.show()

Plot der transformierten Inputdaten

Fourier-Transformation

Um die Resultate unserer Fourier-Transformation zu speichern, bieten sich pandas DataFrames an. Erstelle dazu einen neuen leeren DataFrame mit den Spalten number, frequency, amplitude und phase.

N = len(input_transformed)
fourier_transform = pd.DataFrame(columns = ['number', 'frequency', 'amplitude', 'phase'], index = np.arange(N))

Jetzt implementieren wir die Formel von oben,

Xk=n=0N1xnexp{i2πkNn},X_k = \sum_{n = 0}^{N-1} x_n \cdot \exp\left\{ -i2\pi\frac{k}{N}n \right\},

indem wir die Summe mit Null initialisieren (= 0 + 0j) und xnexp{}x_n \cdot \exp \{ \ldots \} dazuaddieren. Dann teilen wir die Summe durch N, um die Ergebnisse mit der Grösse der Input-Daten zu normalisieren. Dieser Vorgang ist erlaubt, da er sich nur auf die Amplitude auswirkt und alle Amplituden mit demselben Faktor multipliziert werden. Zuletzt berechnen wir die Frequenz als kk (zur Erinnerung: die Fourier-Transformation ordnet jeder Frequenz eine Amplitude und eine Phase zu), die Amplitude als Absolutwert von XkX_k und die Phase als Drehung von XkX_k und speichern sie in dem oben erstellten DataFrame.

for k in range(N):
    X_k = 0 + 0j
    for n in range(N):
        X_k += input_transformed[n] * np.exp(-1j * 2 * np.pi * k * n / N)
    X_k /= N
    frequency = k
    amplitude = abs(X_k)
    phase = np.angle(X_k)
    fourier_transform.iloc[k] = [X_k, frequency, amplitude, phase]

Damit unsere Animation schön aussieht, sortieren wir den DataFrame nach Amplitude. Der Grund dafür ist, dass unser Skript später etwa 1000 Kreise und Linien zeichnen wird, und die meisten davon sind sehr, sehr klein. Es macht also Sinn, dass wir alle Pfeile nach ihrer Grösse (= Wichtigkeit) sortieren, sodass grössere Pfeile zuerst gezeichnet werden und näher an der Mitte liegen.

Diese Operation ist natürlich erlaubt, da die Summe mehrerer Funktionen unabhängig von der Reihenfolge der Summanden ist.

fourier_transform.sort_values(by = 'amplitude', inplace=True, ascending = False)

Zeichnung

Nun wollen wir für jeden Zeitschritt ein Bild erzeugen. Da dies eine Weile dauern kann, habe ich mich dafür entschieden, einen Fortschrittsbalken einzubauen, der den Fortschritt des Render-Prozesses anzeigt. Um diesen zu verwenden, müssen wir die gesamte Zeitschleife folgenden Code packen:

with alive_bar(N) as bar:
    [...]

Dann definieren wir ein paar Variablen, die wir später brauchen. path wird die Summe aller vorherigen Zeitschritte enthalten, sodass wir den Pfad anzeigen können, den unsere Zeichenmaschine bis jetzt erzeugt hat. interval ist der Zeitschritt, der nach jeder Iteration zu time hinzugefügt wird.

path = [] # stores the points of the already calculated sums
 
interval = 2 * np.pi / N
time = 0
⚠️

Die Variable time muss von 0 bis genau 2π2\pi mit genau NN Schritten reichen. Wenn das anders gemacht wird (z.B. mit N1N-1 Schritten, oder nicht bis 2π2\pi), wird die Animation nicht korrekt aussehen! Ich habe schon Stunden damit verbracht, ein einfaches for time in np.linspace(0, 2 * np.pi, N) zu debuggen, bei welchem N+1 korrekt gewesen wäre.

Das Rendern der Bilder erfolgt in mehreren Schritten:

Neues leeres Bild erstellen

Wir erstellen ein leeres Bild mit der Dimension CANVAS_SIZE und einer Hintergrundfarbe.

image = Image.new("RGB", (int(CANVAS_SIZE.real), int(CANVAS_SIZE.imag)), 'white')
draw = ImageDraw.Draw(image)

Summe Berechnen & Zeichnen

Wir zeichnen die Summe aller Funktionen zu einem Zeitpunkt time. Dazu beginnen wir in der Mitte des canvas und durchlaufen dann eine Schleife über alle Punkte der Fourier-Transformation. Der Endpunkt jeder Zeile wird als aktueller Summenwert plus die Funktion beim Index kk berechnet. Dies ist vielleicht besser zu verstehen, wenn man die Exponentialfunktion in trigonometrische Funktionen aufteilt:

aexp{ift+iϕ}=acos(ft+ϕ)+iasin(ft+ϕ)a\exp\left\{ ift + i\phi \right\} = a\cos(ft + \phi) + ia\sin(ft + \phi)

wobei aa die Amplitude, ff die Frequenz und ϕ\phi die Phase ist. Diese Exponentialfunktion ist einfach eine auf zwei Dimensionen erweiterte Wellenfunktion, wie sie am Anfang dieses Posts beschrieben wurde.

Ich berechne dann die Dicke einer Linie in Abhängigkeit von ihrer Länge, da längere Linien dicker sein sollten als kürzere. Dann zeichnen wir die Linie und den Kreis, in dem sie sich dreht, und aktualisieren den start-Wert für die nächste Linie. Am Ende hängen wir start (= die Gesamtsumme) an path an.

start = CANVAS_SIZE / 2
for k in range(N):
    point = fourier_transform.iloc[k]
    end = start + point.amplitude * np.exp(1j * point.frequency * time + 1j * point.phase)
 
    thickness = round(5 * point.amplitude / max(fourier_transform.amplitude))
    draw.line([start.real, start.imag, end.real, end.imag], width=thickness, fill='black')
    draw.circle((start.real, start.imag), point.amplitude, outline='gray', width=1)
 
    start = end
path.append(start)

Es ist auch möglich, kk bis zu einem anderen Wert (N\leq N) in einer Schleife laufen zu lassen, was zu einer weniger detaillierten Animation führt. Hier geht kk von 0 bis 50, was auch sehr cool aussieht!

Fourier Animation Frame mit tieferer Auflösung

Zeichnen des Pfads

Nun zeichnen wir den Pfad (alle bisher berechneten Summen). Wir hören beim vorletzten Element auf, da jeder path[i] auch path[i+1] verwendet und wir somit nur M1M-1 Segmente für MM Punkte haben.

for i in range(len(path)-1):
    draw.line([path[i].real, path[i].imag, path[i+1].real, path[i+1].imag], width=5, fill='black')

Bild Speichern

Zunächst erzeugen wir einen Dateinamen, um sicherzustellen, dass unser Betriebssystem die Bilder richtig sortiert. Dazu berechnen wir die Anzahl der Ziffern, die die höchste Bildnummer haben wird:

num_digits = int(np.log10(N)) + 1 # For filename generation

Dies berechnet die Anzahl der Ziffern, die wir benötigen, um alle Bilder mit derselben Dateinamenskonvention und -länge zu speichern, z.B. frames/0000.png, frames/0001.png, ..., frames/1439.png.

Dann wird der Frame-Index mit dieser Nummer ge-zero-padded (ich schwör, das Wort gibts!), um den Dateinamen zu erzeugen. Stelle dabei sicher, dass der Ordner frames/ existiert.

filename = "frames/%s.png" % str(i).zfill(num_digits)
image.save(filename)

Zeitschritt Erhöhen

Erhöhe den Zeitschritt und schiebe den Fortschrittsbalken auf die nächste Iteration.

time += interval
bar()

Alles in allem sieht der Zeichnungsteil so aus:

# Animation
num_digits = int(np.log10(N)) + 1 # For filename generation
with alive_bar(N) as bar:
    for i in range(N):
        image = Image.new("RGB", (int(CANVAS_SIZE.real), int(CANVAS_SIZE.imag)), 'white')
        draw = ImageDraw.Draw(image)
 
        # path.append(draw_epicycles(time, draw))
        start = CANVAS_SIZE / 2
        for k in range(N):
            point = fourier_transform.iloc[k]
            end = start + point.amplitude * np.exp(1j * point.frequency * time + 1j * point.phase)
            thickness = round(5 * point.amplitude / max(fourier_transform.amplitude))
            draw.line([start.real, start.imag, end.real, end.imag], width=thickness, fill='black')
            draw.circle((start.real, start.imag), point.amplitude, outline='gray', width=1)
            start = end
        path.append(start)
 
        for i in range(len(path)-1):
            draw.line([path[i].real, path[i].imag, path[i+1].real, path[i+1].imag], width=5, fill='black')
 
        time += interval
 
        filename = "frames/%s.png" % str(i).zfill(num_digits)
        image.save(filename)
        bar()

Erstellen des Videos

Zuletzt können wir ffmpeg verwenden, um alle Bilder zu einem Video zu kombinieren. -r 60 gibt die Framerate an, -c:v libx264 ist der Videocodec. Du kannst dafür natürlich auch den Videoeditor deiner Wahl verwenden.

ffmpeg -r 60 -pattern_type glob -i 'frames/*.png' -c:v libx264 fourier_transform_animation.mp4

Und… Fertig!

Damit solltest du nun eine funktionierende Fourier-Serien-Animation haben!

Schick mir gerne deine fertige Animation! Du kannst mir dafür eine Mail zusenden (schochal[at]ethz.ch) oder sie auf Mastodon posten und mich markieren (@alexander_schoch@linuxrocks.online). Ich würde mich ausserdem sehr über Feedback zu diesem Post freuen!

Kompletter Code

Du kannst den Code entweder hier kopieren oder ihn direkt von GitLab herunterladen.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
 
from PIL import Image, ImageDraw
from alive_progress import alive_bar
 
# Input Data
input = pd.read_csv('input.dat', delimiter='\t', header=None)
input_complex = np.array([complex(input[0][i], input[1][i]) for i in range(len(input))])
 
# Constants
CANVAS_SIZE = 800 + 400j
 
TOP_LEFT = -CANVAS_SIZE / 8 * 3
BOTTOM_RIGHT = CANVAS_SIZE / 8 * 3
 
center = (TOP_LEFT + BOTTOM_RIGHT) / 2
target_width = BOTTOM_RIGHT.real - TOP_LEFT.real
target_height = BOTTOM_RIGHT.imag - TOP_LEFT.imag
 
# Transforming (moving / scaling) input to desired dimensions
leftmost_point = min(input_complex.real)
rightmost_point = max(input_complex.real)
topmost_point = complex(0, min(input_complex.imag))
bottommost_point = complex(0, max(input_complex.imag))
 
width = rightmost_point - leftmost_point
height = bottommost_point - topmost_point
margin_top = (target_height - height.imag / width * target_width) / 2
 
input_transformed = (input_complex - leftmost_point - topmost_point) / width * target_width + TOP_LEFT + complex(0, margin_top)
 
# Discrete Fourier Transform
N = len(input_transformed)
fourier_transform = pd.DataFrame(columns = ['number', 'frequency', 'amplitude', 'phase'], index = np.arange(N))
 
for k in range(N):
    X_k = 0 + 0j
    for n in range(N):
        X_k += input_transformed[n] * np.exp(-1j * 2 * np.pi * k * n / N)
    X_k /= N
    frequency = k
    amplitude = abs(X_k)
    phase = np.angle(X_k)
    fourier_transform.iloc[k] = [X_k, frequency, amplitude, phase]
 
fourier_transform.sort_values(by = 'amplitude', inplace=True, ascending = False)
 
path = [] # stores the points of the already calculated sums
 
num_digits = int(np.log10(N)) + 1 # For filename generation
 
interval = 2 * np.pi / N
time = 0
 
# Animation
with alive_bar(N) as bar:
    for i in range(N):
        image = Image.new("RGB", (int(CANVAS_SIZE.real), int(CANVAS_SIZE.imag)), 'white')
        draw = ImageDraw.Draw(image)
 
        start = CANVAS_SIZE / 2
        for k in range(N):
            point = fourier_transform.iloc[k]
            end = start + point.amplitude * np.exp(1j * point.frequency * time + 1j * point.phase)
            thickness = round(5 * point.amplitude / max(fourier_transform.amplitude))
            draw.line([start.real, start.imag, end.real, end.imag], width=thickness, fill='black')
            draw.circle((start.real, start.imag), point.amplitude, outline='gray', width=1)
            start = end
        path.append(start)
 
        for i in range(len(path)-1):
            draw.line([path[i].real, path[i].imag, path[i+1].real, path[i+1].imag], width=5, fill='black')
 
        time += interval
 
        filename = "frames/%s.png" % str(i).zfill(num_digits)
        image.save(filename)
        bar()

Comments

Subscribe via RSS.

MIT 2024 © Alexander Schoch.