Farbton, Sättigung und Helligkeit nach Farbbereich, berechnet in Oklab statt HSL
Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23
Übersetzung der englischen Fassung: English
Kurven verändern jede Farbe, die denselben Ton teilt. Die Werkzeuge in diesem Teil verändern nur manche Farben: die Grüntöne Richtung Gelb schieben, den Himmel abdunkeln, die Haut in Ruhe lassen. Fotoeditoren nennen diese Familie meist HSL. In meinem Farbstudio läuft davon nichts in HSL. Im Folgenden geht es darum, wie ein Farbbereich ausgewählt wird, warum die Bearbeitung in Oklab stattfindet, wie sich die Helligkeit verändern lässt, ohne den Farbton mitzuziehen, wie Haut und Grautöne geschützt werden, und um zwei Fehler, die die erste Version der Mathematik hatte.
Woher der Code kommt
Die Engine ist LUT Atelier, ein früherer Editor von mir aus meinem privaten Hub-Repository, festgepinnt im Studio auf Commit fd96f16 mit einem SHA-256 für jede seiner 87 Dateien (eine davon das mitgelieferte nlohmann-json.hpp); Teil 1 erzählt diese Geschichte. Die Farbmathematik ist C++20 auf der Standardbibliothek. Wo mir ein öffentliches Produkt eine Idee für die Oberfläche gegeben hat, nenne ich es und behaupte nicht, seinen Algorithmus zu verwenden.
Warum nicht HSL
HSL und HSV sind für einen Farbwähler in Ordnung und für die Bearbeitung schlecht. Die HSL-Helligkeit ist Arithmetik auf kodierten Kanälen, (max + min) / 2, deshalb haben reines Rot, Gelb, Grün, Cyan, Blau und Magenta alle eine HSL-Helligkeit von 50 %. In Oklab liegt ihre Helligkeit zwischen 0,45 für Blau und 0,97 für Gelb (decimal commas in all prose numbers). Ein Regler, der in HSL „Helligkeit +10“ verspricht, bewirkt bei verschiedenen Farbtönen sichtbar Verschiedenes, und eine Farbtondrehung bei konstanter HSL-Helligkeit macht aus einem hellen Gelb ein dunkles Blau.

Die Engine enthält zwar eine Funktion rgbToHsl. Das einzige Modul, das sie importiert, ist Anzeigecode, und keine Bearbeitung ruft sie auf. Jedes Bereichswerkzeug arbeitet in Oklab, Björn Ottossons Farbraum: L ist die Helligkeit, und (a, b) ergeben das Chroma C = √(a² + b²) und den Farbton h = atan2(b, a). Ottosson nennt genau diese Aufgabe unter seinen Zielen: die Sättigung anheben, während wahrgenommener Farbton und wahrgenommene Helligkeit gleich bleiben, und er weist darauf hin, dass CIELAB Blautöne schlecht vorhersagt. Bei Blau zeigt eine Farbtonbearbeitung meist ihre Nähte.
Eine Eigenschaft nutze ich direkt. Oklab ist eine Matrix, eine Kubikwurzel und eine zweite Matrix; wenn man also lineares RGB mit k multipliziert, werden L, a und b mit der Kubikwurzel von k multipliziert. Das Verhältnis C/L und der Farbton ändern sich nicht mit der Belichtung. Ich habe es an einer warmen Farbe bei k = 2 geprüft: Jede Koordinate wurde mit 1.2599 skaliert, C/L blieb bei 0.1531 und der Farbton bei 53.42°. Deshalb nutzt das Hautgewicht weiter unten C/L statt C.
Sechs Farbsektoren mit weicher Kante
Das Bereichswerkzeug hat sechs Regler für Helligkeit und sechs für den Farbton. Ihre Mittelpunkte sind die Oklab-Winkel 0°, 60° und so weiter bis 300°. Das sind Winkel, keine Farbnamen: Reines sRGB-Rot liegt in Oklab bei 29°, Gelb bei 110°, Blau bei 264°. Deshalb zeichnet die Oberfläche unter jedem Regler einen Streifen der echten Farbtöne, statt einen davon „Rot“ zu nennen.
Jeder Sektor hat ein Raised-Cosine-Gewicht, das im Abstand von 60° vom Mittelpunkt auf null fällt:
w_i = 0.5 · (1 + cos(π · d_i / 60)) für d_i ≤ 60°, sonst 0
wobei d_i der kreisförmige Abstand zwischen dem Farbton des Pixels und dem Mittelpunkt ist. Zwei benachbarte Gewichte ergeben immer zusammen 1, eine Farbe zwischen zwei Sektoren bekommt also genau eine volle Bearbeitung, aufgeteilt auf beide. Der Schnitt bei 60° ist wichtig: cos(π·d/60) wiederholt sich alle 120°, und ohne den Schnitt würde der 0°-Sektor auch bei 120° und 240° mit voller Stärke wirken.

Nahe Grau ist der Farbtonwinkel Rauschen, deshalb wird die Bearbeitung zusätzlich mit einem Chroma-Gatter q(C) = smoothstep(0.015, 0.05, C) multipliziert. Ein Grau mit einem zufälligen schwachen Farbstich wird nicht aufgehellt, als wäre es orange. Die Schleife im kreativen Teil von color.cpp:
if (hasBands) {
const double chromaWeight = smoothstep(.015, .05, chroma);
double deltaHue = 0.;
for (int i = 0; i < 6; ++i) {
const double distance = hueDistance(hue, i * 60.);
if (distance > 60.) continue;
const double weight = .5 * (1. + std::cos(kPi * distance / 60.)) * chromaWeight * amount;
out[0] += grade.bands[i] * weight;
deltaHue += grade.hueBands[i] * weight;
}
if (deltaHue != 0.) {
const double angle = deltaHue * kPi / 180., cosine = std::cos(angle), sine = std::sin(angle);
const double a = out[1], b = out[2];
out[1] = a * cosine - b * sine; out[2] = a * sine + b * cosine;
}
}
Zwei Entscheidungen sind hier sichtbar. hue und chroma stammen aus der Grundfarbe, bevor andere kreative Bearbeitungen greifen, sodass eine frühere Farbtondrehung kein Pixel unter einen anderen Regler verschieben kann. Und die Farbtonverschiebungen aller sechs Sektoren werden zuerst summiert und dann als eine einzige Drehung angewandt, was L und C auf dieser Stufe unberührt lässt.
Helligkeit ohne Farbtonverschiebung
Ein Helligkeitsregler addiert bis zu ±0.2 zum Oklab-L und lässt a und b in Ruhe. Ein Farbtonregler (±30°) dreht (a, b) und lässt L in Ruhe. Die globalen Regler für Farbton und Sättigung folgen derselben Regel: Die Sättigung skaliert den Abstand von der neutralen Achse, der Farbton dreht den Winkel. Die Mathe-Prüfung (npm run check:math) misst es: Nach einer globalen Farbton- und Chroma-Bearbeitung beträgt die Änderung des Oklab-L 0, und der Chroma-Fehler liegt bei 2.7 × 10⁻¹⁵.
Das gilt auf der kreativen Stufe, und mehr behaupte ich nicht. Danach kommen weiche Farbumfangskompression, Kontrast, Kurven, die Fotostufe und der Kanalmixer. Ist die weiche Kompression eingeschaltet (das Grid Lab aktiviert sie für neue Grades), verschiebt sie eine außerhalb des Umfangs liegende Farbe Richtung des Graus mit demselben L im linearen RGB, weil der feste Oklab-Strahl bei festem L und festem Farbton nahe Blau unsicher ist; Teil 1 erklärt, warum. Mindestens die ersten 80 % der Strecke vom Grau bis zur Umfangsgrenze bleiben exakt. Jenseits dieses Knicks verschiebt die Kompression L und Farbton leicht, und die Dokumentation sagt das, statt so zu tun, als hielte die Invariante.
Haut und Grautöne schützen
Es gibt drei Schutzmechanismen, und sie sind nicht gleich stark.
Die weichen Regler schwächen nur die kreative Stufe. Ihre Gewichte sind:
neutral = 1 − smoothstep(0.015, 0.12, C)
skin = exp(−(d(h, 35°) / 28°)²) · smoothstep(0.015, 0.06, C/L) · smoothstep(0.005, 0.025, L)
Die kreative Bearbeitung wird mit 1 − max(neutralStrength · neutral, skinStrength · skin) skaliert. Die frühere Version schützte Haut mit einer Schwelle bei L = 0.35, was dunkle Haut stillschweigend ungeschützt ließ. Der Wechsel zu C/L hat die Schwelle entfernt; nur die Zone direkt neben Schwarz, wo der Farbton unzuverlässig ist, blendet aus. Gespeicherte Projekte mit Hautschutz schützen dunkle Farben jetzt stärker, und die Änderung war gewollt. Diese Zahlen sind meine technischen Heuristiken, kein veröffentlichtes Hautmodell und kein Klassifikator. Ein warmes braunes Sofa liegt innerhalb der Keule, und Haut unter farbigem Licht kann außerhalb fallen. Die Prüfung deckt die Mathematik an Beispielfarben ab, nicht die Erkennung auf einer Sammlung von Porträts.
Farbtonsperren sind stärker. Jede hat einen Mittelpunkt, eine halbe Breite für vollen Schutz, eine weiche Kante und ein minimales Chroma (also '15° weiche Kante', 'Kanten-Regler'), bis zu zwölf Stück. Die Voreinstellungen im Grid Lab sind Haut bei 50° ±25° mit 15° Feder, Grüntöne bei 145° und Himmel bei 250°; die Pipette erzeugt eine schmale Sperre von ±12° mit 8° Feder. Das Gewicht wird aus dem Originalpixel entnommen, und das Zurückmischen geschieht einmal, ganz am Ende:
out = original + (processed − original) · strength · (1 − maxProtection)
Eine gesperrte Farbe kehrt also zu ihrer exakten Quell-RGB zurück, was auch immer LUT, Weißabgleich oder Kurven bewirkt haben, und die importierte LUT kann nicht ändern, welche Pixel als geschützt gelten.
Der erste Fehler saß hier. Von sRGB nach Oklab umgerechnet ergab ein reines Grau ein winziges Chroma, 3.7 × 10⁻⁸ bei Weiß und weniger bei dunkleren Grautönen, und einen Farbton von 89.9°. Jede Sperre mit minimalem Chroma 0, deren Bereich 90° überdeckte, schützte also jedes Grau im Bild. Die Korrektur übernimmt ihre Schwelle aus CSS Color 4, das den OkLCh-Farbton bei C ≤ 0.000004 als machtlos behandelt. Darunter hat ein Pixel keinen Farbton, und eine Farbtonsperre kann ihn nicht beanspruchen. Die Prüfung rechnet jetzt 256 sRGB-Grautöne um: Das größte Chroma ist 8.5 × 10⁻¹¹, und jedes Sperrgewicht ist 0.
Die Sehne, die durch Grau schnitt
Schutz und gefiederte Auswahlen wenden eine Bearbeitung „teilweise“ an. Die erste Version tat das Naheliegende: Sie berechnete das vollständig gedrehte (a, b) und mischte es dann mit dem ursprünglichen (a, b) nach dem Gewicht. Diese Mischung verläuft auf einer Sehne des Farbkreises, nicht auf dem Bogen. Bei Gewicht 0.5 landet eine Drehung um 180° genau auf Grau, und eine Drehung um 90° verliert 29 % ihres Chromas, weil der Mittelpunkt der Sehne den Abstand |cos(θ/2)| vom Ursprung hat.

Am 22. September haben fünf unabhängige, nur lesende Prüfungen der Engine dies zweimal gefunden: bei der Schutzgewichtung der globalen Farbton- und Sättigungsregler und bei der gefiederten Kante von Selektive Farbe. Beide skalieren die Bearbeitung jetzt selbst in Polarform, Winkel A·θ und Chroma-Faktor 1 + A·(s − 1). Die Version von Selektive Farbe aus photo_color.cpp:
// The feather scales the edit in polar form; a Cartesian mix cut across the hue circle.
const double newChroma = std::hypot(lab[1], lab[2]) * (1. + membership * selection.saturation);
const double angle = std::atan2(lab[2], lab[1]) + membership * selection.hue * kPi / 180.;
lab = {lab[0] + membership * selection.lightness, newChroma * std::cos(angle), newChroma * std::sin(angle)};
Die Prüfung bestätigt, dass bei Zugehörigkeit 0.5 der Chroma-Fehler 3.9 × 10⁻¹⁶ beträgt und sich der Farbton um genau die Hälfte der Anforderung dreht. Die JavaScript-Implementierung und die C++-Engine werden mit denselben Eingaben verglichen: Der polare Schutz stimmt auf 6.1 × 10⁻¹⁵ überein, die Fotostufe mit polarem Selektive Farbe auf 1.1 × 10⁻¹⁴.
Eine Farbe anklicken statt einen Sektor wählen
Sektoren sind absichtlich grob. Für „dieses Grün, nicht alle Grüntöne“ gibt es zwei Werkzeuge mit Stichproben.
Selektive Farbe nimmt bis zu acht Proben. Jede hat einen Radius im Oklab-Raum, der L genauso zählt wie a und b, volle Zugehörigkeit innerhalb eines inneren Radius, der über einen Feder-Regler festgelegt wird (standardmäßig die Hälfte des Radius), und ein Smoothstep-Ausblenden auf null am Radius. Die Zugehörigkeit wird einmal aus der unberührten Quellfarbe berechnet, sodass eine frühere Bearbeitung nicht ändern kann, welche Pixel dazugehören. Die Bearbeitung ist L plus m·ΔL, C mal 1 + m·Δs, h plus m·Δh, mit optionalem Zug zu einer Zielfarbe. Eine geschützte Farbe ist eine ähnliche Probe ohne Bearbeitung: Innerhalb ihres inneren Radius kommt die ursprüngliche RGB exakt zurück.
Farbpins im A/B-Raster gehen weiter: bis zu 24 Anker in der a/b-Ebene, jeder entweder verschoben oder gesperrt. Die interaktive Designvorlage für die Studioseiten fügte pro Anker einen kompakten Kern hinzu und summierte sie. Bei zwei Ankern im halben Radius Abstand, wobei A Δa = 0.10 verlangt und B bleiben will, verschob sich B um 0.10 × φ(0.5) = 0.019. Die Engine löst stattdessen das Kernsystem: K c = d mit K_ij = φ(‖p_i − p_j‖ / R) und dem Wendland-C²-Kern φ(t) = (1 − t)⁴ (4t + 1). Das Feld geht dann exakt durch jede geforderte Verschiebung, und eine Sperre ist eine Verschiebung von null. Die Lösung ist O(n³) für n ≤ 24 und läuft einmal pro Bearbeitung, nicht pro Pixel. Anker, die näher als 0.001 R beieinanderliegen, oder ein System mit einem Pivot unter 10⁻⁵ werden mit einem Fehler abgelehnt, statt schlecht gelöst.
Grenzen
- Alles hier wählt im Farbraum aus, nicht im Bild. Eine beige Wand und eine beige Wange bekommen dieselbe Behandlung. Dieselbe Tatsache macht es exportierbar: Jedes Werkzeug in diesem Teil ist eine Funktion allein des Eingangs-RGB und lässt sich in eine
.cube-Datei einbacken, das ist Teil 6. - Es gibt keine semantische Hautmaske und kein Acht-Bereich-HSL-Panel mit den Bereichsgrenzen eines kommerziellen Editors. Color Mixer und Point Color in Lightroom Classic haben mir gezeigt, wie nützlich es ist, den betroffenen Bereich neben dem Regler anzuzeigen. Ich habe diese Idee für die Oberfläche übernommen, nicht eine Formel.
- Der Pfad ist SDR-sRGB, mit Oklab, berechnet aus linearem sRGB mit D65. Log, HDR und beliebige ICC-Eingaben werden nicht unterstützt.
- Ein stark verschobenes Pin-Feld kann trotzdem zwei Farben auf eine abbilden. Der Begrenzer des Grid Lab prüft das Feld auf Sättigung und Übereinstimmung, aber noch nicht gegen die Schutzgewichte; dieser Fall ist ein offenes Ticket, kein gelöstes.
Der nächste Teil ist der Kanalmixer: eine 3×3-Matrix auf den RGB-Kanälen, bei der nichts von dieser wahrnehmungsbezogenen Maschinerie gilt und die Mathematik einfacher und stumpfer wird.