happyin.work / Blog

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

Belichtung und Weißabgleich: drei Multiplikatoren im linearen Licht

Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23

Übersetzung der englischen Fassung: English

Im Farbstudio, das ich baue, sind Belichtung und Weißabgleich eine einzige Operation: drei Multiplikatoren auf linearem RGB. Alles Interessante liegt um diese Multiplikation herum. Welche Werte als linear gelten, worauf die Verstärkungen normiert werden, damit ein Grau seine Helligkeit behält, und was die Engine tut, wenn ein Kanal über 1 hinausläuft. Dies ist der erste Teil einer Serie darüber, wie die grundlegenden Farbwerkzeuge implementiert sind, mit der Mathematik, dem C++ und den Fehlern, die die Tests aufgedeckt haben.

Die Engine und der Raum, in dem sie arbeitet

Das Studio ist ein lokaler Editor auf Basis von LUT Atelier, einem Farbeditor, den ich zuerst in meinem Forschungs-Hub geschrieben und dann über den SHA-256 jeder Quelldatei ins Studio eingebunden habe. Einige der unten beschriebenen Korrekturen gingen nach dem Festpinnen in diese Engine ein. Die native Seite ist C++20 und verwendet nur die Standardbibliothek; die einzige Drittanbieter-Datei ist nlohmann/json 3.12.0, für das Befehlszeilenprotokoll mitgeliefert. Die JavaScript-Kopie der Mathematik bleibt als unabhängiges Orakel erhalten: Ein seeded Lauf über 600 zufällige Grades vergleicht 196.200 Ausgabewerte zwischen beiden Engines, und die größte Abweichung ist 8.7e-14.

Die Eingabe ist ehrlich in dem, was sie ist. JPEG, WebP und 8-Bit-PNG kommen über das Browser-Canvas als 8-Bit-sRGB an. Ein separates Lab-Fenster liest 16-Bit-PNG, ohne dass das Canvas es rundet. Es gibt keinen RAW-Decoder, kein HDR und kein anderes ICC-Profil als sRGB. Die Farbtransformation rechnet in double und rundet erst beim Schreiben der Ausgabe.

sRGB-Werte sind kodiert, nicht linear. Die Engine dekodiert jeden Kanal mit der Kurve aus IEC 61966-2-1, bevor sie irgendetwas multipliziert:

c_lin = c / 12.92                      if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4      otherwise

Ein mittleres Grau von 0,5 dekodiert zu 0,214. (decimal commas in all prose numbers) Diese Zahl erklärt den größten Teil dieses Artikels.

Die Reihenfolge eines Grades innerhalb der Engine, aus native/src/color.cpp:

  1. Eingangsstufe: optionales Levels der Prüftafel oder eine importierte LUT, mit einem gemeinsamen Stärkeregler gemischt.
  2. Dekodierung nach linear, dann Multiplikation mit den Verstärkungen für Weißabgleich, Belichtung und Temperatur/Tönung.
  3. Optionale Oklab-Bearbeitungen (Farbton, Sättigung, Farbpins, Referenzabgleich), dann zurück zu kodiertem RGB.
  4. Kontrast, Masterkurve, R/G/B-Kurven.
  5. Fotostufe: Levels, Luma-Kurve, Tonwertregler, Vibrance, Farbräder, Selektive Farbe.
  6. Kanalmixer, Farbschutz, globale Stärke.

Belichtung ist eine Multiplikation, also gehört sie ins lineare Licht

Belichtung ist 2^EV, angewandt auf lineares RGB. +1 EV verdoppelt das Licht, −1 halbiert es, und der Regler reicht von −3 bis +3. Der Grund, warum dies in linearen Werten geschehen muss, ist physikalisch: Doppeltes Licht ist ein doppeltes lineares Signal. Es ist nicht ein doppelter kodierter Codewert.

Graurampe bei +1 EV: Multiplikation des linearen RGB clippt ab Eingabecode 188, Multiplikation kodierter Werte clippt ab 128

Die Abbildung stammt direkt aus der Engine. Bei +1 EV kommt Eingabecode 128 bei 175.6 heraus, und Weiß wird ab Eingabecode 188 erreicht. Multipliziert man stattdessen den kodierten Wert mit zwei, wird alles ab Code 128 weiß, und die obere Hälfte der Rampe ist weg. Adobe dokumentiert Photoshops Belichtungskorrektur als Berechnung im linearen Farbraum, und Lightroom beschreibt +1.00 als ungefähr eine Blendenstufe. Ich behaupte nicht, ihre Formel zu verwenden; die Multiplikation ist einfach das, was das Wort bedeutet.

Hier ist der ganze Pfad in C++. Belichtung und Temperatur/Tönung werden einmal pro Grade zu einem Verstärkungsvektor zusammengefasst, und die Pixelschleife macht drei Multiplikationen:

// native/src/color.cpp
Vec3 linearGains(const Grade& grade) {
  const Vec3 warm{1. + grade.temperature * .4, 1. + grade.tint * .25, 1. - grade.temperature * .4};
  const double exposure = std::exp2(grade.exposure), y = linearLuminance(warm);
  return {exposure * warm[0] / y, exposure * warm[1] / y, exposure * warm[2] / y};
}

Vec3 workLinear(Vec3 original, const Grade& grade, const std::optional<Cube>& lut, Vec3 gains) {
  // ... input stage: checker Levels, then the imported LUT, blended by importedStrength ...
  return {srgbToLinear(original[0]) * grade.wb[0] * gains[0],
          srgbToLinear(original[1]) * grade.wb[1] * gains[1],
          srgbToLinear(original[2]) * grade.wb[2] * gains[2]};
}

Die dokumentierten Invarianten sind schlicht. EV 0 ist ein Multiplikator von genau 1, und die Mathe-Suite prüft, dass +1 EV das lineare RGB mit null Fehler verdoppelt. +1 gefolgt von −1 liefert die Eingabe zurück, wo nichts geclippt wurde: Auf der Graurampe habe ich einen Round-Trip-Fehler von 4e-14 eines Codes gemessen.

Weißabgleich: dieselbe Multiplikation, pro Kanal

Der Weißabgleich ist hier eine diagonale Skalierung des linearen RGB, das Modell von Kries: eine Verstärkung pro Kanal. Die Verstärkungen stammen aus zwei Quellen. Die Neutralpipette schreibt wb, und die Regler für Temperatur und Tönung erzeugen relative Multiplikatoren:

w = (1 + 0.4·T,  1 + 0.25·t,  1 − 0.4·T) / Y(w)
Y(w) = 0.2126·w_R + 0.7152·w_G + 0.0722·w_B

Die Division durch Y(w) ist der Teil, den ich zuerst falsch gemacht habe. Ohne sie multipliziert Tönung +1 das Grün mit 1.25, und ein Grau wird um log2(1.179), etwa 0.24 EV, heller; Tönung −1 macht es um 0.28 EV dunkler. Ein Regler, der die Farbe ändern soll, änderte also auch die Belichtung. Mit der Division färben Temperatur und Tönung ein Neutralgrau um, ohne seine lineare Leuchtdichte zu ändern. Ich habe ein mittleres Grau durch Tönung +1 geschickt: Es kommt als (118.1, 131.0, 118.1) heraus, und sein lineares Y ist bis auf das letzte Bit gleich der des Eingangs.

Der Kompass, der diese Regler steuert, ist ein Bild zweier begrenzter Zahlen, kein Thermometer. Die Markierung sitzt bei x = cx + R·tanh(temperature), y = cy − R·tanh(tint), und das Ziehen kehrt dies mit atanh um, begrenzt auf 0.7615, knapp unter tanh(1), dem Ende des Reglerbereichs. Kelvin gibt es darauf nicht. Lightroom zeigt bei Rohdateien Kelvin, bei anderen Dateien eine relative Skala von −100 bis +100; Capture One zeigt Kelvin und Tönung. Ein JPEG wurde meist schon einmal weißabgeglichen, in der Kamera oder im Raw-Konverter, und ohne Kameraprofil gibt es nichts, über das man Kelvin umrechnen könnte. Eine relative Skala ist die ehrlichere.

Die Neutralpipette behält die Helligkeit dessen, was du angeklickt hast

Klickst du auf ein graues Feld, dekodiert die Engine es zu linearem (r, g, b) und setzt

g_c = Y / c,   Y = 0.2126·r + 0.7152·g + 0.0722·b

Nach der Multiplikation sind alle drei Kanäle gleich Y, der angeklickte Punkt wird also zu einem Grau mit seiner eigenen Leuchtdichte. Die Version, die ich festgepinnt hatte, normierte stattdessen auf das geometrische Mittel (r·g·b)^(1/3). Das hält das Produkt der Verstärkungen bei 1 und klingt ordentlich, aber der angeklickte Punkt wurde heller oder dunkler: 0.30 EV bei der Testauswahl (0.6, 0.45, 0.3), und etwa 1.06 EV bei einer grünlichen Auswahl wie (0.3, 0.6, 0.3). Ein Farbwerkzeug verschob stillschweigend wieder die Belichtung. Die Luminanzversion kam am 22. September, und die JS- und C++-Engines stimmen auf dem Prüfsatz bitgenau überein.

// native/src/color.cpp
Vec3 neutralGains(Vec3 rgb) {
  if (!finite3(rgb) || rgb[0] <= 0. || rgb[1] <= 0. || rgb[2] <= 0. || rgb[0] > 1. || rgb[1] > 1. || rgb[2] > 1.) throw std::range_error("neutralGains requires finite encoded sRGB channels in (0, 1]");
  const Vec3 linear{srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])};
  // g_i = Y / c_i: the pick becomes a grey of its own luminance (see color-core.mjs neutralGains).
  const double y = linearLuminance(linear);
  Vec3 gains{y / linear[0], y / linear[1], y / linear[2]};
  for (double gain : gains) if (!std::isfinite(gain) || gain > 8.) throw std::range_error("neutralGains pick needs a gain above grade.wb limit 8: it is too dark or too far from neutral");
  return gains;
}

Eine erfolgreiche Auswahl setzt Temperatur und Tönung außerdem auf null zurück, damit die Verstärkungen auf dem Bildschirm den gesamten Abgleich darstellen. Eine Auswahl, die eine Verstärkung über 8 bräuchte, wird mit dieser Meldung abgelehnt, eine Auswahl mit einem Nullkanal mit einer eigenen Meldung. Ich ziehe eine Ablehnung einer geklemmten Verstärkung vor, weil eine geklemmte Verstärkung ein Ergebnis erzeugt, das so aussieht, als hätte das Werkzeug funktioniert, obwohl es das nicht hat. Der Testfall in der Suite ist eine stark getönte Probe, die Verstärkungen von (4.40, 1.10, 0.24) braucht; nach der Multiplikation sind ihre Kanäle gleich, mit einer Streuung von exakt null.

Worauf du klickst, zählt mehr als die Formel. Ein einzelnes Foto kann die Farbe des Lichts nicht von der Farbe des Objekts trennen: Das lineare Signal ist I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ, und wenn man das Licht E mit einer beliebigen spektralen Funktion multipliziert und die Reflektanz R durch dieselbe Funktion teilt, ergibt sich dasselbe Pixel. Die Pipette gehört deshalb auf eine Graukarte oder auf eine Fläche, von der du weißt, dass sie neutral ist, nie auf Haut oder auf ein „weißes“ T-Shirt mit unbekanntem Farbstoff. Aus demselben Grund gibt es in der Engine noch keinen automatischen Weißabgleich. Jede automatische Methode ruht auf einer Annahme, und die klassische Grauwelt-Annahme macht aus einer Szene, die wirklich grün ist, ein Grau; die Forschungsprüfungen behalten dieses Gegenbeispiel absichtlich bei.

Umgesetzt ist für bekannte Neutralwerte ein Panel „Chart Check“: ein bekanntes neutrales Feld, dessen kodiertes und lineares RGB, die Verstärkungen und der Rest nach ihnen. Für das Feld selbst ist der Rest per Konstruktion null, deshalb ist die nützliche Prüfung die anderen Neutralwerte im Bild, abgelesen an der RGB-Parade (also 'bleibt an der Parade sichtbar'). Ein Löser für mehrere Felder (der Median der Verhältnisse pro Feld) ist aufgeschrieben, aber nicht gebaut. Felder, die einander widersprechen, werden nicht zu einem verdeckten „idealen“ Abgleich gemittelt; der Widerspruch bleibt am Parade sichtbar.

Eine Reihenfolge, die ich noch nicht geändert habe

In der aktuellen Engine läuft die importierte LUT vor Weißabgleich und Belichtung. Für eine Look-LUT ist das verkehrt herum: Man will das Bild zuerst normalisieren und den Look danach anwenden, sonst landet derselbe Look auf jedem Bild einer Serie anders. Ich habe es nicht umgedreht, weil das stillschweigend jedes gespeicherte Projekt verändern würde. Es braucht ein versioniertes Rezept, bei dem alte Projekte die alte Reihenfolge behalten und neue die neue erhalten. Bis dahin liest die Neutralpipette den Pixelwert nach der Eingangsstufe, sodass ihre Verstärkungen wenigstens auf das verweisen, was die Multiplikation tatsächlich erhält.

Was über 1.0 passiert

Die Engine ist SDR. Mit Belichtung und Weißabgleich allein wird jeder Kanal direkt nach der Multiplikation kodiert und auf [0, 1] geklemmt; mit aktiven Oklab-Bearbeitungen kommt das Clamping später, spätestens beim Schreiben des Ergebnisses. Daraus folgen zwei Dinge, und beide sind sichtbar.

Erstens kommt geclipptes Detail nicht zurück. Eine Kurve nach der Belichtung abzusenken dunkelt nur ein flaches 255 ab; die Werte darüber wurden eine Stufe früher verworfen, und die Dokumentation des Belichtungswerkzeugs sagt das.

Zweitens verändert ein kanalweises Clipping den Farbton. Erreicht der rote Kanal 1, während Grün und Blau weiter wachsen, rutscht die Farbe Richtung Gelb.

Farbton einer warmen Farbe bei steigender Belichtung: kanalweises Clipping macht sie gelb, weiche Farbumfangskompression blasst sie Richtung Weiß ab

Die Zahlen stammen aus der Engine. Ein warmes sRGB (204, 133, 87) hat einen Oklab-Farbton von 52.9°. Sein roter Kanal erreicht 255 bei +0.73 EV. Bei +1 EV ist die Ausgabe (255, 182, 121) und der Farbton 60.4°; bei +2 EV ist sie (255, 248, 166), Farbton 103.9°. Ein Orange ist zu einem blassen Gelb geworden.

Es gibt eine optionale Alternative, die weiche Farbumfangskompression. Statt jeden Kanal abzuschneiden, verschiebt sie eine außerhalb liegende Farbe entlang eines geraden Segments im linearen sRGB Richtung des Graus derselben Oklab-Helligkeit, bis sie in den Würfel passt. Nach einer kreativen Oklab-Bearbeitung an einer Farbe, die im Würfel begonnen hat, bleiben mindestens die ersten 80 % der Strecke bis zur Grenze exakt, und nur der Rest wird mit einer exponentiellen Schulter komprimiert. Bei reiner Belichtung ist der Referenzpunkt die belichtete Farbe selbst, die schon außerhalb liegt; dort ist es eine einfache Projektion auf die Oberfläche. Bei derselben warmen Farbe ergibt +1 EV (255, 192, 159) mit Farbton 48.5°: Der Farbton wandert weniger, aber das Chroma fällt von 0.115 (das hart geclippte Ergebnis; die Quellfarbe hat 0.107) auf 0.085, und ab etwa +1.67 EV, wo die Oklab-Helligkeit 1 erreicht, ist das Ergebnis reines Weiß. Keine der beiden Optionen ist eine Lichterrückgewinnung.

Die erste Version war das Lehrbuchmodell: Oklab-Helligkeit und Farbton fest halten und das Chroma entlang dieses Strahls verringern. Sie scheitert nahe Blau. Auf der Blauseite kann ein Strahl mit festem L und festem Farbton den Farbumfang verlassen und wieder betreten, und eine Abbildung, die stetig sein muss, kann diese Lücke nicht überspringen. Die kleine Wiedereintrittsstelle nahe 264° ist meine eigene Messung mit den Matrizen der Engine, nicht seine: Björn Ottossons Oklab-Artikel sagt, ein früher Fit habe die Blautöne nach innen gefaltet, und dass er das endgültige Oklab so beschränkt habe, dass sie es nicht mehr tun. Der Oklab-Rahmen der weichen Option und ihre Warnung vor hartem RGB-Clipping stammen aus seinen Notizen zur Farbumfangsbegrenzung. Das lineare RGB-Segment zu einem Grau derselben Helligkeit hat genau einen Schnittpunkt, weil der RGB-Würfel konvex ist, sodass die Stetigkeit gratis kommt. Sie hat einen Preis, den ich gemessen habe: Bei einer hellen warmen Rampe bei +0.8 EV können benachbarte 8-Bit-Eingabecodes 19 Ausgabecodes auseinanderliegen. Das ist eine steile Antwort, kein Sprung; der Test erlaubt bis 20 und schlägt bei 21 fehl.

Capture One dokumentiert eine Lichterrückgewinnung, die nicht geclippte Kanäle nutzen kann. Das funktioniert auf Rohdaten mit Reserve. Bei 8-Bit-sRGB-Eingaben gibt es nichts über 255 zurückzugewinnen, und ich zeige den Clip lieber ehrlich, als Details zu erfinden.

Grenzen und was ich wieder so machen würde

  • 8-Bit-SDR-sRGB im Hauptfenster, 16-Bit-PNG nur im Lab-Fenster. Kein RAW, kein Kelvin, kein ICC.
  • Weißabgleich aus einem bekannten Feld; mehrere Felder und automatische Schätzung sind entworfen, nicht gebaut.
  • Die importierte LUT läuft noch vor dem Weißabgleich. Die Korrektur braucht ein versioniertes Rezept.

Was ich beim nächsten Mal von Anfang an tun würde: Temperatur und Tönung durch ihre eigene Leuchtdichte teilen, eine zweite Implementierung der Mathematik als Orakel behalten und die Pipette eine Probe mit Begründung ablehnen lassen, statt ihre Verstärkungen zu klemmen.

Nächster Teil: Levels, Histogramme und Schwarz- und Weißpunkte anhand einer Tafel, wo dieselbe Engine absichtlich in kodierten Werten arbeitet. Das On-Device-C++-Retusche-Plugin, das ich für Photoshop ausliefere, ist auf happyin.ai beschrieben.

Colour tools, built from scratch

  1. Belichtung und Weißabgleich: drei Multiplikatoren im linearen Licht
  2. Levels: Schwarz- und Weißpunkte, Gamma, Kanäle und eine Tafel
  3. Kurven: natürlicher Spline gegen PCHIP, Überschwingen, Luma und LUT-Einbrennen
  4. Farbton, Sättigung und Helligkeit nach Farbbereich, berechnet in Oklab statt HSL
  5. Der Kanalmixer, eine 3×3-Matrix: was sie erhält, wo sie clippt, wo sie läuft
  6. 3D-LUTs: Einbrennen eines Farbgrades in .cube und Hald, und was zwischen den Knoten verloren geht

Weitere Beiträge

Mehr auf dieser Website

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face