2025-10-27

Relationale Scharade: Filme in Tabellen verwandeln

Hannes Mühleisen

„Your scientists were so preoccupied with whether they could,
they didn't stop to think if they should.“

Dr. Ian Malcolm, Jurassic Park (1993)

Hier im DuckDB-Team lieben wir Tabellen. Tabellen sind eine zeitlose, elegante Abstraktion, die der Literatur um etwa tausend Jahre vorausging. Relationale Tabellen im Besonderen können jede Art von Information darstellen, die man sich vorstellen kann. Aber nur weil etwas möglich ist, ist es nicht immer eine gute Idee. Können wir eine Rakete bauen, die von einer nuklearen Kettenreaktion angetrieben wird und das Land bestrahlt, über das sie fliegt? Ja. Sollten wir? Wahrscheinlich nicht.

Disclaimer

Array-artige Daten wie Bilder und Videos sind ein Lehrbuchbeispiel für etwas, das vielleicht nicht davon profitiert, in einer Datenbank zu liegen. Natürlich kann jede Binärdatei als BLOB in Tabellen landen, aber der Mehrwert ist nicht so groß. Sicher, das Bild ist schwerer zu verlieren als bei der Branchenlösung, einen Dateinamen zu speichern, der auf das Bild zeigt. Aber es gibt nicht so viele sinnvolle Operationen auf BLOBs außer Speichern und Laden. Ohne irgendwelche überhypte KI-Technik kann man die Datenbank nicht einmal fragen, was das Bild zeigt.

Array-Daten haben außerdem ihre eigene Welt hochspezialisierter Dateiformate und Kompressionsalgorithmen. Denken Sie nur an den allgegenwärtigen MPEG-4-Standard zum Speichern von Filmen. Das sind ungefähre (nicht exakte, verlustbehaftete) Formate, die um menschliche Wahrnehmungsmodelle herum entworfen sind, weshalb sie Dinge weglassen können, die Menschen nicht merken. Sie erreichen beeindruckende Kompressionsraten: Ein zweistündiger „Full“-HD-Film komprimiert mit MPEG-4 auf etwa 2 GB.

Den Disclaimer ignorieren

Aber wie würde es sich anfühlen, einen Film in eine Tabelle zu verwandeln? (Sehr) tief unten ist ein Film nur eine Folge schnell bewegter Bilder („Frames“), typischerweise um die 25 Bilder pro Sekunde. Bei dieser Geschwindigkeit kann unser Affengehirn die Einzelbilder nicht mehr unterscheiden und wird getäuscht, glatte Bewegung zu sehen. Nebenbemerkung für jüngere Generationen: Ein Streifen Bilder war über 100 Jahre lang die Art, wie wir Filme verschickt haben.

Also eine Folge von Bildern. Jedes Bild lässt sich weiter in ein zweidimensionales Array (eine „Matrix“) von Punkten zerlegen, sogenannten „Pixeln“. Jeder Pixel besteht wiederum aus drei Zahlen, je eine für die Intensität von Rot, Grün und Blau, kurz RGB. Hinweis: Die Tonspuren ignorieren wir in diesem Beitrag, aber im Prinzip ginge es genau so, nur mit einer anderen Art von Intensität.

Als zusätzliche Komplexität verlangt das relationale Modell (berühmterweise) keine absolute Ordnung der Datensätze. Alle Offsets müssen also explizit gemacht werden, um keine Information zu verlieren. Das vergrößert unseren Datensatz natürlich erheblich. Am Ende haben wir eine Tabelle, die so aussieht:

i y x r g b
0 0 0 4 5 1
0 0 1 4 5 1
0 0 2 5 6 2
0 0 3 8 9 4
0 0 4 9 10 5
0 0 5 11 12 8
0 0 6 11 12 8
0 0 7 11 12 8
0 0 8 9 10 5
0 0 9 9 10 5

Wir haben den Zeit-Offset oder die Frame-Nummer i, x und y für die Pixelposition im Frame und r, g und b für die Farbkomponenten Rot, Grün und Blau. Ziemlich aufwendig.

Aber jetzt ist der Film nur eine einzige Tabelle. Hätten wir nur eine konventionelle und garantierte totale Ordnung der Zeilen, könnten wir theoretisch alle Spalten außer r, g und b weglassen, weil sich bei bekannter Auflösung alle anderen Spalten ableiten lassen. So werden zufällig auch echte Filmdatendateien gespeichert, Kompression ignoriert. Das ist ein weiterer Grund, warum relationale Tabellen vielleicht nicht der beste Ort für einen Film sind, aber wenn man nur einen Hammer hat. Wir hätten auch modernere SQL-Features nutzen und verschachtelte Felder (ein LIST in DuckDB) nehmen können, aber bleiben wir bei einer Tabelle, mit der sogar System R klargekommen wäre. Explizite Offsets brauchen außerdem keine nebulösen Konventionen oder zusätzliche Metadaten, um zu wissen, in welcher Achsenreihenfolge die Array-Daten serialisiert wurden.

Experimente

Um diese alberne Idee weiter zu untersuchen (für die Wissenschaft!), wandeln wir den 1963er-Klassiker „Charade“, eine „romantische Screwball-Comedy-Mystery“, mit Audrey Hepburn und Cary Grant in eine DuckDB-Tabelle um. Dieser Film wurde gewählt, weil er aus Versehen gemeinfrei ist – wegen eines Patzers in der Formulierung des Copyright-Hinweises (nein, wirklich). Deshalb können Sie diesen Film tatsächlich frei vom Internet Archive herunterladen.

Weil wir nur eine Tabelle anlegen, nutzen wir DuckDBs natives Storage-Format. Hier das vollständige Code-Snippet, mit dem wir den Film konvertiert haben. Tatsächlich sollte dieser Code allgemein genug sein, um alles, was ffmpeg lesen kann, in eine Tabelle zu verwandeln. Falls Sie das zu Hause mit Ihren eigenen Filmen ausprobieren wollen.

import imageio
import duckdb
# setup movie reading
vid = imageio.get_reader("Charade-1963.mp4", "ffmpeg")
dim_x = vid.get_meta_data()['size'][0]
dim_y = vid.get_meta_data()['size'][1]
rows_per_frame = dim_y * dim_x
# setup a DuckDB database and table
con = duckdb.connect()
con.execute("ATTACH 'charade.duckdb' AS m (STORAGE_VERSION 'latest'); USE m;")
con.execute("CREATE TABLE movie (i BIGINT, y USMALLINT, x USMALLINT, r UTINYINT, g UTINYINT, b UTINYINT)")
# those offsets don't change between frames, so pre-compute them
con.execute("CREATE TEMPORARY TABLE y AS SELECT unnest(list_sort(repeat(range(?), ?))) y", [dim_y, dim_x])
con.execute("CREATE TEMPORARY TABLE x AS SELECT unnest(repeat(range(?), ?)) x", [dim_x, dim_y])
# loop over each frame in the movie and insert the pixel data
for i_idx, im in enumerate(vid):
v = im.flatten()
r = v[0:len(v):3]
g = v[1:len(v):3]
b = v[2:len(v):3]
con.execute('''INSERT INTO movie
FROM repeat(?, ?) i -- frame offset
POSITIONAL JOIN y -- temp table
POSITIONAL JOIN x -- temp table
POSITIONAL JOIN r -- numpy scan
POSITIONAL JOIN g -- numpy scan
POSITIONAL JOIN b -- numpy scan
''', [i_idx, rows_per_frame])

Dieses Skript nutzt nicht nur ein, sondern (mindestens) zwei coole DuckDB-Features. Erstens sogenannte Replacement Scans, um die NumPy-Arrays r, g und b direkt abzufragen. Diese wurden nicht als Tabellen in DuckDB angelegt und auch nicht registriert, werden aber namentlich im INSERT referenziert. DuckDB inspiziert den Python-Kontext nach den fehlenden „Tabellen“ und findet Objekte mit diesen Namen, die es lesen kann. Das andere coole Feature ist der POSITIONAL JOIN, mit dem wir mehrere Tabellen horizontal nach Position stapeln, ohne einen echten (teuren) JOIN auszuführen. So setzen wir alle Spalten, die wir für einen einzelnen Frame brauchen, in einem Bulk-INSERT zusammen, das ziemlich effizient läuft.

Die Filmdatei hat 25 Frames pro Sekunde bei einer (DVD-artigen) Auflösung von 720×392 Pixeln. Die Gesamtlaufzeit ist 01:53:02.56, das ergibt 169 563 Einzelbilder. Weil wir eine Zeile pro Pixel haben, enden wir bei 169 563 × 720 × 392 Zeilen, oder 47 857 461 120. 47 Milliarden Zeilen! Endlich Big Data! Als DuckDB-Datenbank ist die Datei aber „nur“ rund 200 GB groß. Auf einem Laptop völlig machbar!

DuckDBs leichtgewichtige Kompression schlägt sich hier ziemlich gut, wenn man bedenkt, dass wir in einem naiven Binärformat mindestens 15 Bytes pro Zeile speichern müssten. Mal der Zeilenzahl (47 Milliarden, erinnern Sie sich) wären das rund 700 GB für dieses hypothetische naive Format.

Natürlich fügen wir durch die Umwandlung in eine relationale Tabelle eine Menge zuvor impliziter Information hinzu, weil Relationen keine Ordnung haben. Würden wir nur die rohen Pixelbytes speichern, etwa als implizit geordnete Folge von BMP-(Bitmap-)Dateien, hätten wir dieselbe Bytezahl wie die Zeilen oben mal drei, oder 133 GB. Selbst einschließlich Materialisierung aller Offsets schafft die DuckDB-Datei eine vergleichbare Größe (200 GB). Und natürlich ist der Größenvergleich mit der MPEG-4-Version des Films nicht ganz fair, weil MPEG-4 ein verlustbehaftetes Kompressionsformat ist. Datenbanken können nicht einfach nach Gutdünken die numerische Genauigkeit der Tabellen opfern, die sie speichern!

Um zu beweisen, dass die Transformation korrekt ist, versuchen wir, die Tabellendaten für einen zufälligen Frame wieder in ein menschenlesbares Bild zu verwandeln: Wir holen die entsprechenden Zeilen aus DuckDB und nutzen etwas Python-Magie, um sie wieder in eine PNG-Bilddatei zu verwandeln:

import duckdb
import numpy as np
import PIL.Image
frame = 48000
con = duckdb.connect('charade.duckdb', read_only=True)
dim_y, dim_x = con.execute("SELECT max(y) + 1 dim_y, max(x) + 1 dim_x FROM movie WHERE i=0").fetchone()
res = con.execute("SELECT r, g, b FROM movie WHERE i = ? ORDER BY y, x", [frame]).fetchnumpy()
v = np.zeros(dim_y * dim_x * 3, dtype=np.uint8)
v[0:len(v):3] = res['r']
v[1:len(v):3] = res['g']
v[2:len(v):3] = res['b']
img = PIL.Image.fromarray(v.reshape((dim_y, dim_x, 3)))
img.save(f'frame.png')

Und voilà, wir sehen einen wunderbaren Frame mit Audrey und Cary. Dieser Trick lässt sich auch nutzen, um eine Folge von Bildern zu erzeugen und sie wieder in eine MPEG-4-Datei zu schreiben – zum Beispiel mit der Bibliothek moviepy.

Aber jetzt, da wir eine Tabelle haben, können wir etwas Spaß damit haben. Zuerst etwas grundlegende Exploration: Wir beginnen mit DESCRIBE, das uns im Wesentlichen das Schema sagt. Das wussten wir natürlich.

DESCRIBE movie;
column_name column_type null key default extra
i BIGINT YES NULL NULL NULL
y USMALLINT YES NULL NULL NULL
x USMALLINT YES NULL NULL NULL
r UTINYINT YES NULL NULL NULL
g UTINYINT YES NULL NULL NULL
b UTINYINT YES NULL NULL NULL

Keine Überraschungen. Wie viele Zeilen gibt es?

FROM movie SELECT count(*);
count_star()
47857461120

Ah ja, 47 Milliarden. Was sind die numerischen Eigenschaften der Spalten? DuckDB hat diese nette SUMMARIZE-Anweisung, die Single-Pass-Zusammenfassungsstatistiken auf einer Tabelle (oder beliebigen Abfrage) berechnet.

SUMMARIZE movie;

Das hier ist zugegeben etwas Angeberisch. DuckDB kann auf einem MacBook in ca. 20 Minuten aufwendige Zusammenfassungsstatistiken über alle 47 Milliarden Zeilen berechnen. Hier die Ergebnisse:

column_name column_type min max approx_unique avg std q25 q50 q75 count null_percentage
i BIGINT 0 169562 150076 84781.0 48948.621846957954 42429 84751 127137 47857461120 0.00
y USMALLINT 0 391 430 195.5 113.16028455346597 98 196 294 47857461120 0.00
x USMALLINT 0 719 840 359.5 207.84589644146592 180 359 540 47857461120 0.00
r UTINYINT 0 255 252 65.32575855816732 44.85627602555231 27 54 96 47857461120 0.00
g UTINYINT 0 249 249 56.79713844669577 37.03562456032193 28 44 77 47857461120 0.00
b UTINYINT 0 255 252 43.249715985643995 38.39218963268899 16 28 61 47857461120 0.00

Weil wir im Wesentlichen eine Menge Farben speichern: Wie viele unterschiedliche Kombinationen aus Rot, Grün und Blau gibt es, DuckDB?

FROM (FROM movie SELECT DISTINCT r, g, b)
SELECT count(*);

Jeder erfahrene Data Engineer würde Sie zu Recht warnen, ein DISTINCT über so viele Zeilen laufen zu lassen. Es gab einfach zu viele Produktionsausfälle durch überlaufende Aggregationen. Aber dank DuckDBs Larger-than-Memory-Aggregat-Hashtabelle können wir diese Abfrage selbstbewusst absetzen. Wir bekommen sogar einen netten Fortschrittsbalken und (seit 1.4.0) eine überraschend genaue Schätzung, wie lange die Abfrage dauern wird.

count_star()
826568

Also grob 800 Tausend unterschiedliche Farben. Die Berechnung hat am Ende etwa 2 Minuten gedauert. Aber wie sind die Häufigkeiten dieser Farben? Berechnen wir ein Histogramm der 10 am häufigsten genutzten Farben!

FROM movie
SELECT r, g, b, count(*) AS ct
GROUP BY ALL
ORDER BY ct DESC
LIMIT 10;
r g b ct
17 20 15 106521429
23 25 15 93004303
23 25 13 85552738
13 22 15 81734796
22 24 13 76560295
24 26 15 75376896
15 19 8 74285763
23 24 19 72904497
22 24 12 69269099
24 26 16 62230136

Die häufigsten Farben scheinen dunkle Grautöne zu sein. Macht Sinn! Denken Sie daran, dass die MPEG-4-Kompression verlustbehaftet ist und wahrscheinlich ein paar seltsame Farben als Rundungsartefakte erzeugt.

Aber wir können auch mehr Spaß haben. Wir haben ein analytisches Datenbanksystem. Wie wäre es, den Durchschnittsframe für je tausend Frames zu berechnen und die Ergebnisse wieder zu einem Film zusammenzunähen? Es ist nur eine große Aggregation. Zuerst erzeugen wir die eigentlichen Durchschnitte:

CREATE TABLE averages AS
FROM movie
SELECT
i // 1000 AS idx,
y,
x,
avg(r)::UTINYINT AS r,
avg(g)::UTINYINT AS g,
avg(b)::UTINYINT AS b
GROUP BY ALL
ORDER BY idx, y, x;

Dann nutzen wir wieder Python, um diese Tabelle averages in einen Film zu verwandeln:

# some setup omitted
# fetch a bunch of frames in bulk
res = con.execute("SELECT r, g, b FROM averages ORDER BY i, y, x").fetchnumpy()
# split the rgb arrays by frame again
r_splits = np.split(res['r'], num_frames)
g_splits = np.split(res['g'], num_frames)
b_splits = np.split(res['b'], num_frames)
# generate pictures
image_files = []
for i in range(num_frames):
v = np.zeros(dim_y * dim_x * 3, dtype=np.uint8)
v[0:len(v):3] = r_splits[i]
v[1:len(v):3] = g_splits[i]
v[2:len(v):3] = b_splits[i]
image_files.append(v.reshape((dim_y, dim_x, 3), order='C'))
# write movie file
clip = moviepy.video.io.ImageSequenceClip.ImageSequenceClip(image_files, fps=25)
clip.write_videofile('averages.mp4')

Es gibt hier etwas Wrangling, weil wir den ganzen Frame-Datensatz auf einmal holen wollen und nicht für jeden eine Abfrage ausführen. Dann nutzen wir NumPy, um sie in Frames zu teilen und die RGB-Kanäle zu dem dreidimensionalen Array zusammenzusetzen, das die Bildbibliotheken mögen. Das erfüllt keinen Geschäftszweck, aber die Ergebnisse sind irgendwie lustig – hier Durchschnittsframe #68, mit Entschuldigung an die Schauspieler:

Wir können auch alle Durchschnitte zusammennähen und einen etwas zuckenden Durchschnittsfilm machen:

Klicken Sie hier, um den zuckenden Film aus „Charade“ zu sehen:

Für noch mehr Spaß könnten wir sogar eine SQL-Abfrage schreiben, die einen Frame in eine HTML-Tabelle mit Ein-Pixel-Feldern verwandelt. Unten das Ergebnis – hoffen wir, dass Ihr Browser das rendern kann, und danken wir Cloudflare nochmal für das Sponsoring unseres Traffics. Hier die etwas unheilige Abfrage dafür:

SELECT '<html><body><table style="padding:0px; margin: 0px; border-collapse: collapse;">';
FROM movie
SELECT
IF(x = 0, '<tr>', '') ||
printf('<td style="background-color: #%02x%02x%02x; height: 1px; width: 1px";></td>', r, g, b) ||
IF(x = 719, '</tr>', '')
WHERE i = 48000
ORDER BY y, x;
SELECT '</table></body></html>';

Das Ergebnis sehen Sie in movies-table.html (bedenken Sie, dass es 20 MB sind und jedes Filmpixel als Tabellenfeld gerendert wird!).

Fazit

Sie merken wahrscheinlich, dass dieser Beitrag nicht ganz ernst gemeint ist. Spaß wurde gehabt. Aber was haben wir gelernt? Ein paar Dinge: Erstens lässt sich im Grunde alles als Tabelle darstellen, sogar ein obskurer Film von 1963. Im großen Ganzen ist es wahrscheinlich keine großartige Idee – es gibt erstaunliche Open-Source-Bibliotheken wie ffmpeg und Apps wie VLC für Filmdateien, oder ähnlich für ihre Array-Cousins mit Musik oder nur Bildern. Trotz der massiven Aufblähung und Milliarden Zeilen Daten hat DuckDB das ziemlich gut gemeistert, sowohl sein Datenformat als auch seine Ausführungsengine. Hier im DuckDB-Team ist unsere Mission, Ihr Vertrauen im Umgang mit Daten aller Formen und Größen zu steigern, und wir hoffen, dass dieser Beitrag dazu beiträgt. Und zum Schluss: Achten Sie einfach auf Ihre Copyright-Hinweise!