Erstellen einer Animation mittels Fouriertransformation (German)
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
- uns ansehen, was eine Fourier-Reihe und eine Fourier-Transformation sind (Link),
- Inkscape verwenden, um eine SVG-Datei in Koordinatenpunkte umzuwandeln, die wir verwenden können (Link),
- Python verwenden, um Bilder für die Animation zu erzeugen (Link), und
- ffmpeg verwenden, um alle Frames zu einem Video zu kombinieren (Link).
Wir werden uns nicht ansehen,
- wie die Fourier-Transformation ihre Magie macht,
- wie man Python benutzt, und
- wie die benutzten mathematischen Grundlagen funktionieren (spezifisch: komplexe Zahlen, Summation, Exponentialfunktion, Trigonometrie).
Daher gehe ich davon aus, dass du
- einige Grundkenntnisse in Python hast, und
- weisst, was komplexe Zahlen sind und wie man sie benutzt (wir werden uns allerdings komplexe Exponentialfunktionen anschauen).
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:
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 . Problem gelöst). Kosinuswellen sind einfach verschobene Sinuswellen: . 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)
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:
- Die Frequenz : Wie schnell schwingt eine Funktion?
- Die Amplitude : Wie “gross” ist eine Funktion?
- Die Phase : Um wie viel nach links oder rechts ist eine Funktion verschoben?
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
wobei ein punkt auf dem Input-Bild, die Fouriertransformation am index und die Anzahl Input-Punkte darstellt.
Diese Formel zeigt uns aber bereits ein weiteres Problem: Unser Input wird zweidimensional sein und eine - und eine -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:
Mit dieser Eigenschaft ist sichtbar, dass die komplexe Exponentialfunktion eine Oszillation in zwei Dimensionen beschreibt.(source code)
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 -Richtung. Stell dir vor, du stehst auf der -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:
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.
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.
Dein Pfad sollte nun etwa so aussehen (Node Tool):
Export
Klicke auf “Extensions” > “Export” > “Export XY” und kopiere den Inhalt in eine neue Datei input.dat
.
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: ). 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 -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 -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 -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()
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,
indem wir die Summe mit Null initialisieren (= 0 + 0j
) und 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 (zur Erinnerung: die Fourier-Transformation ordnet jeder Frequenz eine Amplitude und eine Phase zu), die Amplitude als Absolutwert von und die Phase als Drehung von 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 mit genau Schritten reichen. Wenn das anders gemacht wird (z.B. mit Schritten, oder nicht bis ), 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 berechnet. Dies ist vielleicht besser zu verstehen, wenn man die Exponentialfunktion in trigonometrische Funktionen aufteilt:
wobei die Amplitude, die Frequenz und 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, bis zu einem anderen Wert () in einer Schleife laufen zu lassen, was zu einer weniger detaillierten Animation führt. Hier geht von 0 bis 50, was auch sehr cool aussieht!
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 Segmente für 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.