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 imageioimport duckdb
# setup movie readingvid = 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 tablecon = 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 themcon.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 datafor 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 duckdbimport numpy as npimport 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 movieSELECT r, g, b, count(*) AS ctGROUP BY ALLORDER BY ct DESCLIMIT 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 bGROUP BY ALLORDER 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 bulkres = con.execute("SELECT r, g, b FROM averages ORDER BY i, y, x").fetchnumpy()
# split the rgb arrays by frame againr_splits = np.split(res['r'], num_frames)g_splits = np.split(res['g'], num_frames)b_splits = np.split(res['b'], num_frames)
# generate picturesimage_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 fileclip = 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 movieSELECT 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 = 48000ORDER 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!