Kurven: natürlicher Spline gegen PCHIP, Überschwingen, Luma und LUT-Einbrennen
Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23
Übersetzung der englischen Fassung: English
Ein Kurveneditor sieht aus wie ein Zeichenwerkzeug, darunter steckt aber eine einzige Entscheidung mit sichtbaren Folgen: wie man eine glatte Linie durch die gesetzten Punkte zieht. Meine Farb-Engine bietet drei Antworten und nutzt zwei davon an verschiedenen Stellen. Dieser Teil erklärt, warum, was Überschwingen mit einem Bild macht, wie sich eine RGB-Masterkurve von einer Luma-Kurve unterscheidet (mit Messungen aus der Engine) und was mit einer Kurve passiert, wenn sie in eine LUT eingebrannt wird.
Was eine Kurve in dieser Engine ist
Eine Kurve hat 2 bis 16 Punkte, beide Achsen 0…1, mit streng wachsendem x. Die Enden bleiben erhalten; ein Klick fügt einen Punkt hinzu, die Pfeiltasten verschieben den gewählten Punkt um 1/255 (mit Umschalt 10/255), Entf entfernt einen inneren Punkt. In der Pipeline aus Teil 1 wird zuerst die Masterkurve auf jeden kodierten RGB-Kanal angewandt, dann die Kurven für R, G und B, und die Luma-Kurve später, in der Fotostufe. Es gibt drei Interpolationsmodi: linear, smooth (PCHIP) und spline (natürlicher kubischer Spline). Ein Modus gilt gemeinsam für Master-, R-, G- und B-Kurve; die Luma-Kurve ist immer PCHIP. Ein Projekt, das vor dem Modusfeld gespeichert wurde, behält linear, denn damit wurde es erstellt.
Die Koeffizienten werden einmal pro Grade berechnet. In der Pixelschleife gibt es eine Segmentsuche über höchstens 15 Intervalle und eine Horner-Auswertung, ohne Allokation und ohne Validierung:
// native/src/color.cpp
double sample(double input) const {
if (input <= x.front()) return y.front();
if (input >= x.back()) return y.back();
std::size_t index = 0;
while (input > x[index + 1]) ++index;
if (input == x[index + 1]) return y[index + 1];
const double t = (input - x[index]) / (x[index + 1] - x[index]);
if (mode == CurveMode::linear) return y[index] + (y[index + 1] - y[index]) * t;
const double value = ((d[index] * t + c[index]) * t + b[index]) * t + y[index];
return mode == CurveMode::spline ? clamp(value) : value;
}
Die letzte Zeile ist die ganze Geschichte dieses Artikels in einem Ausdruck: Nur der Spline braucht ein Clamping.
Der natürliche kubische Spline und sein Preis
Der Splinemodus löst nach den zweiten Ableitungen M_i an den Punkten. Mit h_i = x_(i+1) − x_i und den Sekantensteigungen d_i ergibt sich ein tridiagonales System:
h_(i−1)·M_(i−1) + 2(h_(i−1) + h_i)·M_i + h_i·M_(i+1) = 6(d_i − d_(i−1)), M_0 = M_(n−1) = 0
Die Engine löst es mit dem Thomas-Algorithmus, wenn sich die Punkte ändern, und speichert dann kubische Koeffizienten pro Segment in lokalem t. Wert, erste und zweite Ableitung sind an jedem Punkt stetig, was die weichste Form ist, die man durch eine Punktmenge legen kann. Neue RGB-Kurven verwenden ihn standardmäßig. Ich habe ihn gegen den natürlichen CubicSpline von SciPy als externes Orakel geprüft: Die JavaScript-Kopie stimmt auf 6.7e-16 überein, und die C++-Engine stimmt mit der JavaScript-Kopie auf 3e-13 überein, auch bei einer Kurve, die stark genug ist, um zu clippen.
Der Preis ist das Überschwingen. Ein natürlicher Spline weiß nicht, dass die Ausgabe in 0…1 bleiben muss, oder dass eine Kurve durch steigende Punkte weiter steigen sollte.

Dies sind die vier Punkte, die ich für die Browserprüfung des Splinemodus verwendet habe: (0, 0), (0.36, 0.2), (0.52, 0.68), (1, 1). Vor dem Clamping erreicht der Spline −0.037 und 1.041. Auf einer dichten Rampe von 10.001 Eingaben liegt er unter 0 für Eingaben bis 0.235 und über 1 ab 0.714. Nach dem SDR-Clamping wird daraus ein flaches Schwarz über etwa die ersten 60 Eingabecodes und ein flaches Weiß über die oberen 73. In diesen flachen Bereichen ist die Kurve nicht mehr C², und sie wächst auch nicht mehr streng monoton: Schattendetails unterhalb von Code 60 werden zu einem einzigen Wert. Bei dieser Kurve unterscheiden sich Spline und PCHIP um bis zu 31 Ausgabecodes, bei Eingabe 0.70. Das ist kein Stilunterschied in der Linienform; es ist ein anderes Foto.
PCHIP: keine neuen Extrema, eine Ableitung weniger
Der Modus smooth ist PCHIP, die stückweise kubische Hermite-Interpolation mit Fritsch-Butland-Tangenten (Fritsch und Butland, 1984), die die Monotoniebedingungen von Fritsch und Carlson, 1980 erfüllen. Jede innere Tangente ist ein gewichtetes harmonisches Mittel der beiden benachbarten Sekanten und wird auf null gesetzt, wenn die Sekanten unterschiedliche Vorzeichen haben oder eine von ihnen null ist:
m_k = (w1 + w2) / (w1/d_(k−1) + w2/d_k), w1 = 2h_k + h_(k−1), w2 = h_k + 2h_(k−1)
m_k = 0 if d_(k−1)·d_k <= 0
Die Endtangenten nutzen eine einseitige Drei-Punkt-Schätzung mit Grenzen für Vorzeichen und Größe, dieselbe Regel, die PchipInterpolator von SciPy verwendet. Das Ergebnis geht durch jeden Punkt, hat eine stetige erste Ableitung und erzeugt zwischen benachbarten Punkten nie ein neues Extremum. Steigen die Punkte, steigt die Kurve. Die zweite Ableitung kann an einem Punkt springen, das ist die eine Ableitung weniger in der Überschrift; in der Praxis zeigt sich das als etwas weniger gerundete Biegung.
Die Mathe-Suite tastet eine monotone PCHIP-Kurve an 1.025 Eingaben ab und prüft, dass kein Schritt nach unten geht; der kleinste gemessene Schritt ist 2.2e-6. Bei der Kurve in der Abbildung ist der kleinste Schritt von PCHIP 1.5e-6, während der geclippte Spline Schritte von exakt null hat. Im Editor ist der PCHIP-Modus mit „kein Überschwingen“ beschriftet.
Warum beide behalten? Weil sie unterschiedliche Wünsche beantworten. Für eine sanfte Biegung durch Punkte weit innerhalb des Bereichs liefert der Spline die weichere Form, und der Unterschied ist klein. Für eine starke S-Kurve nahe Schwarz und Weiß bewahrt PCHIP Verläufe in Schatten und Lichtern, die der Spline wegwirft. Der Modus ist ein einziger Schalter, und er bleibt mit dem Projekt gespeichert.
Masterkurve für RGB gegen Luma-Kurve
Eine RGB-Masterkurve wendet dieselbe Funktion auf kodiertes R, G und B an. Ein Grau bleibt grau. Eine Farbe behält ihre Kanalverhältnisse nicht, daher erhöht eine Kontrastkurve auch die Sättigung und dreht den Farbton ein wenig. Die Luma-Kurve ist die Antwort auf „Ton ändern und die Farbe in Ruhe lassen“. Sie arbeitet auf linearer Leuchtdichte mit BT.709-Gewichten, Y = 0.2126R + 0.7152G + 0.0722B, und baut RGB so zusammen, dass die neue Leuchtdichte genau f(Y) ist:
// native/src/photo_color.cpp
std::array<double, 3> linear{srgbToLinear(levelled[0]), srgbToLinear(levelled[1]), srgbToLinear(levelled[2])};
// Luma curve: RGB' = RGB * (f(Y) - f(0)) / Y + f(0), so Y' = f(Y) exactly (see photo-color.mjs).
const double y = lumaOf(linear);
const double mappedY = luma.sample(y);
if (y > kLumaFloor) {
for (double& channel : linear) channel = channel * (mappedY - lumaBlack) / y + lumaBlack;
linear = clipToLuminance(linear, mappedY);
} else {
linear = {mappedY, mappedY, mappedY};
}
Beide Zeilen um die Verstärkung sind Korrekturen. Die erste Version skalierte RGB mit f(Y)/Y. Bei angehobenem Schwarz, f(0) > 0, explodiert die Verstärkung nahe Schwarz: Ein Blau, einen Code über null, wurde zu 255. Behandelt man die Anhebung als neutralen Offset und addiert f(0) zu allen drei Kanälen, bleibt sie eine Anhebung. Die zweite Korrektur betrifft Kanäle, die außerhalb von 0…1 landen. Sie einzeln zu clippen verfehlte die Zielleuchtdichte um bis zu 0.137 und drehte den Farbton um bis zu 13.6°. Die Engine verwendet jetzt die ClipColor-Konstruktion aus ISO 32000-1 §11.3.5.3, Teil der PDF-Spezifikation für nichtseparable Mischmodi: Kanäle außerhalb des Bereichs werden zum Grau derselben Leuchtdichte gezogen, sodass Y' = f(Y) auch dort gilt, wo ein Kanal clippen würde. Die Mathe-Suite misst den Leuchtdichtefehler der Luma-Kurve unter 1e-15.
Um zu sehen, was das bringt, habe ich eine S-Kurve auf beide Arten durch die Engine laufen lassen. Die Masterkurve war eine PCHIP-S-Kurve auf kodierten Werten; die Luma-Kurve war so gebaut, dass Grautöne bis auf 0.25 Codes gleich herauskommen.

Bei sechs Testfarben erhöhte die RGB-Masterkurve das Oklab-Chroma um 15 bis 31 % und drehte den Farbton um bis zu 4.0°. Die Luma-Kurve änderte das Chroma um −8 % bis +7 % und drehte den Farbton um 0.0°. Das Chroma bewegt sich trotzdem ein wenig, weil das Oklab-Chroma auch bei konstanter Farbart der Helligkeit folgt. Capture One dokumentiert dieselbe Absicht für seine Luma-Kurve: Kontrast ohne Sättigungszuwachs. Adobes Camera Raw-Punktkurve bietet Komposit- und kanalweise Kurven. Keiner von beiden veröffentlicht eine Formel, und meine ist meine eigene.
Eine Folge für die Oberfläche: Die Luma-Kurve lebt auf einer linearen Leuchtdichteskala, deshalb bedeuten dieselben Punktkoordinaten andere Töne als auf der Masterkurve. Der Editor zeigt sie auf ihrer eigenen Skala und zeichnet dafür keine Kanalüberlagerungen.
Kurven auf wahrnehmungsbasierten Achsen: entworfen, nicht gebaut
Ein Kandidat für ein Kurvenwerkzeug ist aufgeschrieben, aber weder im Panel noch in der Engine. Es setzt drei Kurven auf Oklab-Achsen, L2 = fL(L), a2 = fa(a), b2 = fb(b), mit gesperrten Mittelpunkten, fa(0) = 0 und fb(0) = 0, sodass jedes Neutralgrau neutral bleibt; die Sperre lässt sich nur durch eine ausdrückliche Aktion lösen, mit einer Warnung, dass sich Neutralgrau verschieben wird. Das Ergebnis wird über ein Maskengewicht m und die Stärke s eingemischt, out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2), und vor der Anwendung werden zwei Prüfungen angezeigt: eine gebrochene Neutralgrau-Sperre und Farben, die den Farbumfang verlassen. Eine Farbumfangskompression muss dort ein ausdrücklicher Modus sein, niemals ein stilles Clamping.
Kurven in eine LUT einbrennen
Alles Globale in einem Grade (Ton, Farbe, Kurven, die Eingangs-LUT, Farbschutz) hängt nur vom Eingangs-RGB ab, daher lässt es sich als 3D-CUBE oder Hald-Bild exportieren. Räumliche Bearbeitungen wie Klarheit oder Dunst hängen von Nachbarn und Position ab, und die Engine weigert sich, sie in eine LUT zu schreiben. Der Editor exportiert den reinen Farbanteil eines solchen Grades erst, nachdem du ausdrücklich bestätigt hast, dass die räumlichen Bearbeitungen weggelassen werden. Das Einbrennen tastet die vollständige Transformation an N³ Gitterpunkten ab, mit N von 2 bis 65, Rot am schnellsten.
An den Gitterpunkten stimmt der CUBE mit der Engine überein: 3e-8 für einen Grade nur mit Spline bei 33³, was Float-Genauigkeit ist, und 1.7e-6 für einen Grade, der alle globalen Regler nutzt, innerhalb der Schwelle von 2e-6 der Prüfung. Zwischen den Punkten ist es Interpolation, und es ist ehrlich zu sagen, wie groß der Fehler dort ist. Bei diesem vollen Grade ergaben 4.096 Samples außerhalb des Gitters einen RMS-Fehler von 0.010 (etwa 2.6 Codes) bei 33³ und 0.0054 bei 65³. Der schlechteste Einzelwert war weit schlechter: Bei 33³ kam eine nahezu gelbe Eingabe mit einem um 0.53 zu hohen Blaukanal heraus, und bei 65³ lag der schlechteste Wert immer noch 0.27 daneben. Die Prüfung markierte den Export als HOLD, also: Der CUBE ist eine Annäherung an diesen Grade, keine Kopie, und das native Bild oder das Projekt ist die getreue Ausgabe. Die Engine hat einen Verify-Aufruf, der genau diese zwei Zahlen zurückgibt, Maximum und RMS über 4.096 Samples, für den gerade exportierten Grade. Teil 2 zeigt denselben Effekt an einem einzelnen Levels-Satz, bei dem ein 65³-Gitter nahe Schwarz um rund 23 Codes danebenlag.
Präzision
Die Farbtransformation läuft in double und rundet einmal, wenn sie die 8-Bit- oder 16-Bit-Ausgabe schreibt. Kurven werden pro Pixel analytisch ausgewertet, nicht über eine interne 1D-Tabelle. Das Hauptfenster arbeitet auf dem 8-Bit-sRGB-Canvas des Browsers, und eine steile Kurve auf einer 8-Bit-Quelle verteilt 256 Eingangsstufen über einen größeren Bereich, daher entsteht Banding. Das Lab-Fenster liest 16-Bit-PNG, ohne es durch das Canvas zu schicken, exportiert 16-Bit-PNG und kann einen geordneten 8×8-Dither anwenden, wenn es auf 8 Bit reduzieren muss. Der Dither hängt von der Pixelposition ab und ist daher nie Teil einer LUT. Nichts davon stellt Abstufungen wieder her, die ein JPEG nie hatte; es verhindert nur, dass die Bearbeitung mehr zerstört.
Das Ziehen an der Kurve hatte ein eigenes Problem (likewise 'ein Zug mit 61 Ereignissen' → 'ein Ziehvorgang mit 61 Ereignissen'), gemessen in Zeit statt in Codes. In der ersten Version zeichnete ein Zug mit 61 Ereignissen die Kurve einmal, 3,56 s nach der ersten Bewegung (and use decimal commas for all prose numbers: 0,037, 13,6°, 0,25 Codes …). Jetzt wird der Graph bei jedem Animationsframe unabhängig von der Verarbeitung neu gezeichnet, der Editor schickt jeweils eine Vorschau an die C++-Engine und behält nur die neuesten wartenden Parameter, und das Kurvenhistogramm wird pro Quellbild zwischengespeichert. Derselbe Zug zeichnet jetzt 61 Kurvenframes, der erste nach 33.9 ms, und das erste verarbeitete Bild nach 267 ms. Ein Rückgängig macht weiterhin den ganzen Zug rückgängig.
Grenzen
- Der natürliche Spline ist der Standard für RGB-Kurven, und er kann an den Enden clippen; PCHIP ist der Modus für starke Kurven nahe Schwarz und Weiß.
- Luma ist immer PCHIP, auf linearer Leuchtdichte, und erhebt keinen Anspruch auf Gleichheit mit Capture One oder Adobe.
- Kurven auf wahrnehmungsbasierten Achsen sind ein Entwurf, kein Feature.
- Ein eingebrannter CUBE eines komplexen Grades ist eine Annäherung; prüfe maximalen und RMS-Fehler, bevor du sie irgendwohin schickst.
Nächster Teil: Farbton, Sättigung und Helligkeit nach Farbe. Wenn dich die native Seite mehr interessiert als die Farbe, steht das On-Device-C++-Retusche-Plugin auf happyin.ai.