3D-LUTs: Einbrennen eines Farbgrades in .cube und Hald, und was zwischen den Knoten verloren geht
Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23
Übersetzung der englischen Fassung: English
Jedes Werkzeug in den früheren Teilen dieser Serie, vom Belichtungsregler bis zum Kanalmixer, berechnet eine neue Farbe aus der alten Farbe und sonst nichts. Eine solche Funktion lässt sich als Tabelle speichern: Man tastet sie auf einem Gitter von RGB-Eingaben ab, behält die Ausgaben und interpoliert dazwischen. Diese Tabelle ist eine 3D-LUT, und so verlässt ein Grade mein Farbstudio in ein anderes Programm. Dieser Teil behandelt die beiden Formate, die Interpolation, die Tabellengröße, was der Export sich weigert mitzunehmen, und was ich gemessen habe, als ich ein echtes Einbrennen gegen die Engine geprüft habe.
Was eine LUT tragen kann
Eine 3D-LUT ist eine abgetastete Funktion T: [0,1]³ → [0,1]³. Sie antwortet auf dasselbe RGB überall gleich: auf einem Gesicht, einer Wand, in einem anderen Foto. Sie kann also Belichtung, Weißabgleich, Kurven, Levels, die Oklab-Bereichswerkzeuge, Farbtonsperren, geschützte Farben und den Mixer enthalten. Sie kann nichts enthalten, das die Position oder die Nachbarn eines Pixels betrachtet: Textur, Klarheit, Schärfen, Dunst entfernen, koordinatenbasierte Masken.
Die Engine verlässt sich nicht darauf, dass die Oberfläche das im Gedächtnis behält. Der Exporthandler in main.cpp verweigert es selbst:
if (type == "cube" || type == "hald" || type == "verify") {
if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");
Sind räumliche Bearbeitungen aktiv, listet der Exportdialog auf, was weggelassen wird, und die Option „nur Farbe“ bleibt unangekreuzt; nur ein ausdrückliches Ankreuzen schickt einen neutralen räumlichen Kontext. Die native Ablehnung ist das Sicherheitsnetz, damit ein Fehler im Dialog keine LUT erzeugen kann, die das Schärfen still verwirft.
Die .cube-Datei, streng gelesen
Das Format ist Adobes Cube LUT Specification 1.0 von 2013 (archivierte Kopie von Adobes PDF): eine Textdatei mit TITLE, LUT_3D_SIZE, optional DOMAIN_MIN und DOMAIN_MAX, dann N³ Zeilen mit je drei Zahlen, wobei Rot am schnellsten wechselt, sodass der Zeilenindex r + N·g + N²·b ist. Die Spezifikation erlaubt N von 2 bis 256. Mein Parser akzeptiert 2 bis 65, nur 3D-Tabellen. Er weist 1D- und Shaper-Dateien zurück, unbekannte Direktiven, nicht endliche Zahlen und eine falsche Zeilenanzahl mit einer Meldung, die das Problem benennt, und er nimmt die Eingabe als kodiertes sRGB, statt aus dem Dateinamen ein Log- oder Kameraprofil zu erraten.
Fünf unabhängige, nur lesende Prüfungen am 22. September fanden drei Parser-Fehler; der erste zeigte sich, als das Studio seine eigenen Dateien wieder einlas:
- Ein
#innerhalb eines in Anführungszeichen stehendenTITLEwurde als Kommentar gestrichen, sodass das Studio einige seiner eigenen Exporte nicht wieder importieren konnte. - Die JavaScript- und C++-Parser waren sich bei 9 von 50 Dateien einer Testsammlung uneins. Jetzt teilen sie sich eine Grammatik, in beiden Sprachen mit derselben regulären Definition einer Zahl geschrieben.
- Der Schreiber gab Nicht-ASCII-Bytes in
TITLEweiter. Die Spezifikation erlaubt nur Zeichen von Leerzeichen bis Tilde, ohne das Anführungszeichen. Der Schreiber behält diese Zeichen jetzt und macht alles andere zu einem Leerzeichen.
Hald: eine LUT, verkleidet als Bild
Eine Hald-CLUT speichert denselben Würfel als Bild. Bei Level L hat der Würfel N = L² Werte pro Achse, und das Bild ist L³ Pixel breit; gelesen als eine lange Pixelzeile, ändert sich Rot am schnellsten, dann Grün, dann Blau. Die Hald-Beschreibung in ImageMagick ist die Primärquelle, der ich gefolgt bin: Level 8 ist ein Bild von 512 × 512 Pixeln mit 64³ Farben.

Der Arbeitsablauf ist einfach: ein neutrales Hald speichern, es in einem beliebigen Editor mit reinen Farboperationen bearbeiten, die Größe beibehalten und es als LUT wieder öffnen. Das Studio schreibt auch seinen aktuellen Grade als Hald. Das Format hat keine Möglichkeit, zu beweisen, dass es ein Hald ist, deshalb prüft der Importer nur die Form: ein quadratisches PNG, dessen Kantenlänge eine Kubikzahl ist, Level 2 bis 8, vollständig undurchsichtig. Jedes quadratische Foto mit 512 × 512 besteht diese Prüfung und wird zu Unsinn, weshalb die Dokumentation ausdrücklich rät, kein gewöhnliches Foto zu importieren.
Zwei Fehler steckten im Bildpfad, nicht in der Mathematik. Der Hald-Import lief über das Browser-Canvas, und das Canvas wandelt die Farben eines PNG um, dessen gAMA-, cHRM- oder iCCP-Chunks etwas anderes als sRGB beschreiben. Ein markiertes Hald kam um bis zu 136 Codes verschoben zurück, bevor auch nur ein Tabellenwert gelesen war. Der Import dekodiert das PNG jetzt selbst und nimmt seine 8- oder 16-Bit-Samples als Zahlen. Der Export war nur 8-Bit; jetzt ist er 16-Bit. Der Unterschied ist vor jeder Bearbeitung sichtbar: 64 Stufen treffen keine ganzen 8-Bit-Codes, daher ist ein Identitäts-Hald in 8 Bit an seinen Knoten schon um bis zu 0,0019 daneben, in Einheiten von 0..1. In 16 Bit ist derselbe Fehler 7,3 × 10⁻⁶.
Trilinear oder tetraedrisch
Zwischen den Knoten interpoliert die Engine auf eine von zwei Arten. Trilinear mischt alle acht Ecken der Zelle. Tetraedrisch sortiert die drei Bruchkoordinaten, wählt eines von sechs Tetraedern innerhalb der Zelle und mischt vier Ecken. Aus cube.cpp:
if (fr >= fg && fg >= fb) { p0=c000; p1=c100; p2=c110; p3=c111; t1=fr; t2=fg; t3=fb; }
else if (fr >= fg && fr >= fb) { p0=c000; p1=c100; p2=c101; p3=c111; t1=fr; t2=fb; t3=fg; }
else if (fr >= fg) { p0=c000; p1=c001; p2=c101; p3=c111; t1=fb; t2=fr; t3=fg; }
else if (fr >= fb) { p0=c000; p1=c010; p2=c110; p3=c111; t1=fg; t2=fr; t3=fb; }
else if (fg >= fb) { p0=c000; p1=c010; p2=c011; p3=c111; t1=fg; t2=fb; t3=fr; }
else { p0=c000; p1=c001; p2=c011; p3=c111; t1=fb; t2=fg; t3=fr; }
return {p0[0] + (p1[0] - p0[0]) * t1 + (p2[0] - p1[0]) * t2 + (p3[0] - p2[0]) * t3,
p0[1] + (p1[1] - p0[1]) * t1 + (p2[1] - p1[1]) * t2 + (p3[1] - p2[1]) * t3,
p0[2] + (p1[2] - p0[2]) * t1 + (p2[2] - p1[2]) * t2 + (p3[2] - p2[2]) * t3};
Beide reproduzieren jede affine Funktion exakt. Bei nichtlinearen Tabellen unterscheiden sie sich, und keines gewinnt überall. Bei einer 2³-Tabelle von f = r·g·b am Punkt (0,2; 0,5; 0,8) gibt trilinear die exakten 0,08 zurück und tetraedrisch 0,2. „Tetraedrisch ist genauer“ ist also kein Gesetz.
Tetraedrisch ist trotzdem der Standard, und die Gründe dafür betreffen nicht die Genauigkeit. Die Adobe-Spezifikation bittet, für 3D-Tabellen tetraedrische Interpolation zu verwenden, und die Interpolationsaufzählungen von OpenColorIO ordnen „best“ für eine 3D-LUT dem Tetraeder zu, also tastet das Studio eine Datei so ab, wie das Format es erwartet. Das zweite Argument ist geometrisch. Jedes der sechs Tetraeder hat die Zellendiagonale von c000 nach c111 als Kante, daher wird ein graues Eingangssignal nur aus grauen Knoten interpoliert. Wenn der Grade Graustufen an den Knoten neutral hält, hält tetraedrisch sie auch zwischen den Knoten neutral. Trilinear mischt die farbigen Ecken hinein.
Um die Größe des Effekts zu sehen, habe ich einen synthetischen Look (eine S-Kurve auf Oklab-L, Chroma × 1,5, Farbton +12°, auf sRGB geklemmt) in eine 17³-Tabelle eingebrannt und mit einer Python-Portierung des Samplers der Engine abgetastet. Das ist eine Abbildung, keine Messung der Engine.

Trilinear färbt die Graurampe um bis zu 1,74 Codes; tetraedrisch bleibt bei 1,4 × 10⁻⁵ Codes, und dieser Rest stammt von der Oklab-Matrix mit zehn Stellen, nicht von der Interpolation. Entlang eines farbigen Verlaufs liegen die beiden nah beieinander, 15,1 und 13,9 Codes im schlimmsten Fall, und beide haben ihre Spitze genau dort, wo ein Kanal des Looks durch null geht und zu clippen beginnt.
Wie groß die Tabelle sein muss
Der Export bietet 17³, 33³ und 65³. Sie sind als kompakt, Standard und dicht beschriftet. Die Option 65³ hieß früher „exakt“; eine Prüfung des Einbrennens hat etwas anderes gemessen, und die Beschriftung wurde geändert.
Die eigene Prüfung der Engine, „Verify export“, brennt die Tabelle ein, serialisiert und parst sie neu und vergleicht sie dann mit der direkten Transformation an 4096 deterministischen Farben, die nicht auf dem Gitter liegen:
for (int index = 0; index < 4096; ++index) {
const atelier::Vec3 rgb{((index * 73 + 17) % 4099) / 4098., ((index * 193 + 47) % 4093) / 4092., ((index * 503 + 29) % 4091) / 4090.};
const atelier::Vec3 direct = transform(rgb);
const atelier::Vec3 sampled = atelier::sampleCube(decoded, rgb, grade.interpolation);
for (std::size_t channel = 0; channel < 3; ++channel) {
const double error = std::abs(direct[channel] - sampled[channel]);
maximum = std::max(maximum, error);
sum += error * error;
}
}
Sie meldet den maximalen und den RMS-Fehler. Das ist ein Stichprobentest, keine Schranke, und kein wahrnehmungsbezogenes ΔE.
Am 12. September habe ich sie auf einem bewusst schweren Grade laufen lassen, bei dem jeder globale Regler gleichzeitig eingeschaltet war, von einer importierten LUT und einem Pin bis zu einer warmen Farbtonsperre und Selektive Farbe. Das war Engine 0.3.0, vor den Korrekturen vom 22. und 23. September, die in Teil 4 beschrieben sind.
| Tabelle | Dateigröße | Fehler an den Knoten | Maximum außerhalb des Gitters | RMS außerhalb des Gitters |
|---|---|---|---|---|
| 33³ | 1,16 MB | 1,7 × 10⁻⁶ | 0,529 | 0,0102 |
| 65³ | 9,13 MB | 1,7 × 10⁻⁶ | 0,267 | 0,0054 |
An den Knoten (alle 35.937 bei 33³, jeder vierte Knoten pro Achse bei 65³) reproduziert die Datei die Engine bis auf Float-Genauigkeit. Dazwischen nicht. Die schlechteste Probe war ein fast gelbes Pixel am Rand des Farbumfangs, dessen Blaukanal 0,002 sein sollte; die 33³-Tabelle lieferte 0,531. Eine Tabelle mit Knoten im Abstand 1/32 kann keine Änderung darstellen, die schmaler ist als dieser Abstand, und eine Schutzkante oder ein Clipping ist genau so eine Änderung. Die Verdopplung der Auflösung halbierte den Fehler und beseitigte ihn nicht. Der Beleg markiert die Knoten mit PASS und den Raum dazwischen mit HOLD und hält die Schlussfolgerung fest, die ich jetzt für den Export verwende: Es ist eine globale RGB-Annäherung, und wenn ein solcher Grade exakt reproduziert werden muss, ist der Träger das PNG oder das gespeicherte Projekt.
Der synthetische Look verhält sich genauso. Sein RMS-Fehler sinkt mit jeder Verdopplung von N um etwa die Hälfte, während das Maximum, getrieben vom Clipping, langsamer fällt.

Den Stapel einbrennen
Das Einbrennen selbst sind drei verschachtelte Schleifen. bakeCube in cube.cpp nimmt eine kompilierte Transform, bei der die Pin-Systeme schon gelöst, die Kurven kompiliert und der Grade validiert sind, und wertet sie an N³ Knoten in der Reihenfolge aus, in der Rot am schnellsten wechselt, wobei Floats gespeichert werden. Dieselbe Transform-Klasse rendert die Vorschau, das Vollbild-PNG und die Tabelle, sodass Vorschau, Vollbild-PNG und Tabelle eine gemeinsame C++-Implementierung des Grades teilen. Eine JavaScript-Kopie (compileTransform, bakeCube) bedient weiterhin die Miniaturen, die Cube-Ansicht und das Grid Lab.
Eine Lektion ging in die andere Richtung: Nicht alles sollte innerhalb der Engine durch eine Tabelle laufen. Die Levels, die der ColorChecker-Löser berechnet, wurden früher durch eine abgetastete 65³-LUT angewandt, was bis zu 16,3 Codes Fehler in die Schatten brachte. Jetzt werden sie exakt am Eingang ausgewertet, und die Prüfung misst den Unterschied bei 1,1 × 10⁻¹⁶.
Farbmanagement: was der Export nicht tut
Die LUTs des Studios sind kodiertes sRGB nach kodiertem sRGB, SDR, und sonst nichts. Das Hauptfenster dekodiert JPEG, WebP und 8-Bit-PNG über das Browser-Canvas als sRGB des Browsers. Der 16-Bit-PNG-Pfad existiert nur im Grid Lab: Er behandelt ein PNG ohne Markierung als sRGB (likewise „und er weist … zurück“) und sagt das, und es weist ein nicht unterstütztes Profil zurück, statt es stillschweigend umzuwandeln. Es gibt kein ICC-Lesen, kein ICC-Profil, das in das PNG geschrieben wird (ein 16-Bit-Export trägt nur die sRGB- und gAMA-Chunks), kein Log-, HDR- oder RAW-Eingangsformat, und der Exporthinweis sagt, dass das Monitorprofil nicht enthalten ist.
Profilbewusste Ein- und Ausgabe ist als Kandidatenwerkzeug mit einem Abnahmevertrag beschrieben, nicht implementiert. Der Vertrag: das eingebettete ICC-Profil lesen oder anhalten und nachfragen, niemals stillschweigend sRGB annehmen; einmal in einen deklarierten linearen Arbeitsfarbraum umwandeln; das gewählte Ausgabeprofil in das PNG schreiben und in einem Test zurücklesen; die Anzeigetransformation von den Arbeitspixeln trennen; eine Monitorkalibrierungs-LUT niemals in einen CUBE, ein Hald oder ein Projekt durchsickern lassen. Photoshop kann eine Farbsuche-Tabelle als ICC-Profil exportieren, zusätzlich zu CUBE, 3DL und CSP (Adobe: Farbsuche-Tabellen exportieren). Mein Exporter schreibt nur CUBE und Hald.
Einen Look auf ein anderes Bild übertragen
Eine LUT überträgt die Funktion, nicht die Absicht. Wenn das zweite Bild unter wärmerem Licht entstanden ist, kommt dieselbe Haut mit einem anderen RGB an, und die LUT antwortet anders. Meine Forschungsnotizen für die nächste Version beschreiben die Lösung als zwei Stufen: eine Normalisierung pro Bild N_i, auf passenden Feldern angepasst und eingefroren, danach der gemeinsame Look T, sodass Bild i den Wert T(N_i(x)) erhält. In der aktuellen Engine kommt die importierte LUT zuerst, und Weißabgleich und Belichtung danach, sodass diese Reihenfolge noch nicht verfügbar ist.
Eine LUT aus einem einzigen Vorher-Nachher-Foto zurückzugewinnen hat eine harte Grenze. Wenn jede Farbe im Foto ein R unter 0,6 hat, stimmen T und T + δ·max(R − 0,6, 0)² auf jedem beobachteten Pixel überein und unterscheiden sich überall sonst. Das Foto sagt nichts über den Teil des Würfels aus, den es nicht enthält. Deshalb ist ein Hald der verlässliche Träger: Es enthält jeden Knoten. Es erklärt auch die Regeln für das Bearbeiten eines solchen Bildes. Kein Skalieren, kein JPEG, keine lokalen Masken oder Schärfung und keine automatische Korrektur, die sich auf dem Hald selbst neu berechnet; die automatischen Parameter auf dem Foto einfrieren und dieselbe Funktion auf das Hald anwenden. Eine versteckte Farbprofilkonvertierung im anderen Editor wird Teil der extrahierten LUT.
Wo eine LUT aufhört
Alles, was von der Position abhängt, bleibt aus der Tabelle heraus, und zwar absichtlich, und ein großer Teil echter Retusche ist genau diese Art Arbeit: Abwedeln und Nachbelichten, Volumen, Haut. Das ist die andere Hälfte dessen, was ich baue. Die Modelle hinter Happyin.ai erzeugen Karten pro Pixel auf dem eigenen Rechner des Retuscheurs, und ein C++-Photoshop-Plugin setzt sie als Ebenen zusammen. Nichts davon ließe sich als LUT transportieren.
Damit schließt sich die Serie. Sie begann mit Belichtung und Weißabgleich, der ersten Stufe derselben Kette, die dieser Teil in eine Tabelle verwandelt.