Levels: Schwarz- und Weißpunkte, Gamma, Kanäle und eine Tafel
Autorin: Anastasiia Butova, Ingenieurin für ComfyUI und Diffusionsmodelle, Belgrad · veröffentlicht 2026-09-23
Übersetzung der englischen Fassung: English
Levels ist das Tonwertwerkzeug mit dem geringsten Geheimnis: eine Klemme, eine gerade Linie und eine Potenz. In meinem Farbstudio arbeitet es absichtlich auf kodierten sRGB-Werten, nach der Multiplikation im linearen Licht aus Teil 1. Die Formel braucht drei Zeilen. Dieser Artikel handelt von allem drumherum: der Reihenfolge der Master- und Kanalsätze, davon, was das Histogramm tatsächlich misst, den Pipetten-Lösern, Schwarz- und Weißpunkten aus einer Farbtafel und einer LUT, die sechzehn Codes neben Schwarz kostete.
Die Formel
Für einen Kanalwert x im Bereich 0…1, mit Eingangs-Schwarz b, Eingangs-Weiß w, Gamma γ und Ausgangs-Schwarz und -Weiß ob, ow:
u = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)
b landet bei ob, w landet bei ow, und alles außerhalb des Eingangsbereichs wird abgeschnitten. Mit b = 0, w = 1, γ = 1, ob = 0, ow = 1 ist die Funktion die Identität. Das Panel zeigt Ein- und Ausgabepunkte als 0…255 und das Gamma als 0,2…5. Der Editor hält mindestens 1/255 zwischen Eingangs-Schwarz und -Weiß ein, und das Modell selbst verweigert w − b < 1e-6. Für das Ausgangspaar gilt diese Regel nicht: Ein Ausgangs-Schwarz über dem Ausgangs-Weiß ist eine Umkehrung, und eine Umkehrung ist eine legitime Anforderung.
Der graue Griff sitzt dort, wo die Kurve die Mitte des Ausgabebereichs ausgibt:
m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5
Ein Gamma über 1 verschiebt den Griff Richtung Schwarz und hellt die Mitteltöne auf; ein Gamma unter 1 tut das Gegenteil.

Adobe dokumentiert dieselben Regler für Photoshop-Levels, und Capture One geht weiter: Seine Levels-Seite nennt ein lineares Beispiel, Eingabe 0–220 auf Ausgabe 0–244, und beschreibt den mittleren Regler als Gamma. Keiner von beiden veröffentlicht eine Implementierung, deshalb behaupte ich keine Bit-Gleichheit mit einem von beiden. Ich behaupte, dass die Funktion oben die ist, die beide Engines im Studio ausführen.
Warum kodierte Werte und nicht lineares Licht? Belichtung und Weißabgleich modellieren Licht, also multiplizieren sie lineares RGB. Levels ist eine tonale Umverteilung, die Menschen gegen ein Histogramm von Codewerten steuern, und ein Gamma-Griff im linearen Licht würde dort sitzen, wo niemand ihn erwartet. Deshalb sind die Graupipette von Levels und die Neutralpipette des Weißabgleichs getrennte Operationen: Die eine biegt das Gamma jedes Kanals in kodierten Werten, die andere multipliziert lineare Kanäle.
Erst der Master, dann jeder Kanal
Ein Grade enthält einen Mastersatz und drei Kanalsätze. Der Mastersatz steht in denselben flachen Feldern, die er immer hatte, sodass ein Projekt, das vor den Kanalsätzen gespeichert wurde, byte-identisch bleibt. Jeder Kanal läuft zuerst durch den Master und dann durch seinen eigenen Satz:
// native/src/photo_color.cpp
double applyLevels(const LevelsSet& set, double x) {
const double normalized = clamp((x - set.blackPoint) / (set.whitePoint - set.blackPoint));
return set.outputBlack + (set.outputWhite - set.outputBlack) * std::pow(normalized, 1. / set.gamma);
}
// ... inside CompiledPhoto::apply
const LevelsSet master = masterLevels(grade);
for (int c = 0; c < 3; ++c) levelled[c] = applyLevels(grade.channelLevels[c], applyLevels(master, encoded[c]));
// Levels alone never needs Oklab: skipping the round trip keeps exact zeros and ones exact.
if (levelsOnly) return levelled;
y_c = f_c(f_master(x_c)) ist meine Entscheidung für diese Engine, keine dokumentierte Tatsache über die interne Reihenfolge von Photoshop.
Der Unterschied zwischen beiden Satzarten ist der Grund, warum beide existieren. Dieselbe Funktion auf allen drei Kanälen hält ein Grau grau, erhält aber das Verhältnis R:G:B einer Farbe nicht, daher bewegt sich die Sättigung mit dem Kontrast. Ein kanalweiser Satz verschiebt die Kanäle auseinander, und genau das entfernt einen Stich, und genau das erzeugt einen, wenn man ihn falsch einsetzt.
Der Kommentar in diesem Ausschnitt verbirgt eine Falle, die ich erst an Zahlen verstanden habe. Innerhalb der Fotostufe läuft Levels vor jeder Oklab-Arbeit, aber die globale Farbtransformation davor schickte früher jedes Pixel durch Oklab und zurück, und dieses Hin und Zurück ist nur in exakter Arithmetik die Identität. Das Gamma von Levels verstärkte dann den Rest nahe null: Bei γ = 5 verlässt ein Kanal, der 0 sein sollte, aber als 1e-6 zurückkommt, Levels als 0.063, sechzehn 8-Bit-Codes. Die erste Oklab-Umkehrung der Engine benutzte die veröffentlichten Matrizen mit zehn Stellen und lieferte die Identität nur bis auf 1.18e-6. Sie ist jetzt eine exakte Umkehrung (1.9e-14), ein Grade, dessen globale Regler kein Oklab brauchen, überspringt diesen Durchlauf ganz, und ein Fall mit γ = 5 in der Suite zeigt null Differenz zwischen JS und C++.
Die zweite Falle war leiser. Eine Zeit lang wendete die JavaScript-Engine kanalweises Levels an, und die C++-Engine, die die Vorschau und jeden Export rendert, verwarf es stillschweigend. Ein unabhängiges Audit fand Abweichungen von bis zu 0.1875. Ein Graupipetten-Fix am Pixel (158, 128, 97) kam in JS als (127.5, 127.5, 127.5) heraus und in C++ unverändert. Jetzt wenden beide die Sätze an, der native Parser weist unbekannte Kanalnamen zurück, statt sie zu überspringen, und die Paritätsprüfung über 257 Samples zeigt 1.1e-16.
Was das Histogramm misst
Das Histogramm über den Levels-Reglern hat 256 Bins. R, G und B werden getrennt in dieselben Bins gezählt, jedes Pixel nach seinem Alpha gewichtet, vollständig transparente Pixel werden übersprungen. Es tastet höchstens 32.768 Pixel mit einem Schritt ab, zeichnet die Quadratwurzel der Häufigkeit, damit die Spitzen nicht alles andere flachdrücken, und schattiert die Bereiche unter dem Schwarzpunkt und über dem Weißpunkt.
Entscheidend ist, wo gemessen wird: an der tatsächlichen Eingabe von Levels, direkt aus der Rendering-Pipeline. Die erste Version führte für das Histogramm und die Kurvenanzeigen eine gekürzte Kopie des Grades erneut aus, und die Kopie behielt einige spätere Stufen und ließ Teile von Levels weg. Ein Audit fing einen reinen Gelbton (255, 255, 0) ein, der im Histogramm „vor Levels“ bei (229, 217, 102) lag, und eine Rot-Anzeige mit aktivem Kanalmixer, die von der echten Eingabe abwich: 139.2 gegen 158.1. Jetzt meldet eine einzige kompilierte Pipeline ihre eigenen Zwischeneingaben, und beide Anzeigen nutzen sie. Auf den 21 Fällen der Regressionssuite ist der Fehler 0.
Pipetten: drei Löser, die ablehnen
Die drei Pipetten lösen aus einem angeklickten Pixel die Parameter pro Kanal. „Pro Kanal“ ist der springende Punkt: Dieselbe Anpassung auf allen drei Kanälen kann ungleiche Kanäle nicht gleich machen, sie kann also keinen Stich entfernen. Für einen Zielwert t ist die benötigte Position im Eingangsbereich u = ((t − ob)/(ow − ob))^γ, und dann
black eyedropper: b = (s − u·w) / (1 − u)
white eyedropper: w = b + (s − b) / u
grey eyedropper: γ_c = ln(u_c) / ln(v_c), u_c = (s_c − b)/(w − b), v_c = (t − ob)/(ow − ob)
Für die Graulösung müssen u_c und v_c beide streng innerhalb (0, 1) liegen. An einem Endpunkt gibt es kein Gamma, deshalb wird ein geclipptes oder zerdrücktes Pixel mit seinem Grund abgelehnt und nie durch Gamma 1 ersetzt, was aussähe, als hätte das Werkzeug nichts getan. Ein Gamma außerhalb von 0.2…5 wird ebenfalls abgelehnt. Das Standard-Grauziel ist der eigene Mittelwert der Probe, sodass das Pixel ungefähr seine Helligkeit behält und nur den Stich verliert.
Diese Löser sind im Audit auf 6.7e-16 exakt. Ehrlicher Stand: Sie leben in levels-picker.mjs und sind noch nicht mit dem Panel verbunden. Das Panel bearbeitet den Mastersatz, und Kanalsätze erreichen die Engine derzeit nur aus gespeicherten Projekten. Der Tafelweg unten hat seinen eigenen Levels-Datensatz pro Kanal am Eingang des Grades.
Schwarz- und Weißpunkte aus einer Tafel
Die erste Version der Pipetten schickte das angeklickte Pixel auf reines Schwarz und reines Weiß, was den Standardwerten von Photoshop entspricht und für eine ColorChecker-Tafel falsch ist. Die veröffentlichten sRGB-Werte des Classic24, die X-Rite zugeschrieben werden, setzen das Weißfeld auf 243 und das Schwarzfeld auf 52. Schickt man das Weißfeld auf 255, wirft man alles weg, was die Szene zwischen 243 und 255 enthält; schickt man das Schwarzfeld auf 0, zerdrückt man 52 Stufen Schatten. Der eigene Belichtungsleitfaden von X-Rite sagt dasselbe mit klaren Worten: Das weiße Quadrat soll sehr hell sein, aber nicht reines Weiß, und das schwarze Feld soll nicht als reines Schwarz erscheinen. Das Weißfeld ist ein diffuser Reflektor mit etwa 90 %, deshalb soll ein spiegelndes Glanzlicht im Bild heller sein als das.
Photoshop erlaubt es, die Zielfarben seiner Pipetten zu bearbeiten, was zeigt, dass 0 und 255 eine Konvention sind und keine Eigenschaft des Werkzeugs. Deshalb nehmen die Löser ein Ziel an, und es gibt eine Tafelvoreinstellung mit den veröffentlichten Werten (für Tafeln, die nach November 2014 hergestellt wurden; ältere Tafeln haben andere Zahlen).
Schwarz und dann Weiß anzuklicken hat ein zweites Problem: Der zweite Klick verschiebt den Eingangs-Weißpunkt, und jede Position innerhalb des Bereichs wandert mit, sodass das Schwarzfeld sein Ziel verlässt. Eine freihändige Pipette kann das nicht vermeiden, weil jeder Klick nur seine eigene Referenz kennt. Mit einer Tafel sind beide Referenzen gleichzeitig bekannt, also wird das Paar zusammen gelöst:
w − b = (s_w − s_b) / (u_w − u_b), b = s_b − u_b · (w − b)
Der durchgehend verdrahtete Pfad beginnt bei einem separaten UNet/VAE-Modell aus meinem Farbtafel-Projekt. Es sagt voraus, wie ein Classic24 an einer Stelle aussähe, die du im Bild wählst, und liefert so 24 virtuelle Felder statt einer erkannten physischen Tafel. Ein Python-Löser macht daraus Levels pro Kanal:
# packages/checker/vendor/checker_levels.py, nested inside solve(); docstring omitted.
# black_target and white_target are the chart's published values for patches 24 and 19.
def ends(gamma: np.ndarray):
low, high = black_target ** gamma, white_target ** gamma
span = (white_sample - black_sample) / (high - low)
return black_sample - low * span, span
Das Gamma kommt aus den vier Graustufen zwischen den Enden. Jedes Grau verlangt sein eigenes Gamma pro Kanal, gefunden durch 60 Bisektionsschritte innerhalb 0.2…5, und die Median-Antwort gewinnt. Ein Kleinste-Quadrate-Mittel lässt ein schlechtes Feld alles mitziehen: Bei drei Graustufen, die Gamma 1 verlangen, und einer vierten, die auf 0.02 zerdrückt ist, verlangte der Fit 2.74. Ein einzelnes Grau allein ist auf andere Weise zerbrechlich: Auf dem ersten echten Foto, das ich gemessen habe, war Feld 20 allein bei drei von fünf Platzierungen nicht erreichbar, während die Rampe darum herum gewöhnlich war.
Der Löser erlaubt Eingangspunkte außerhalb von 0…1, denn ein Schwarzpunkt unter 0 ist das, wie eine Anhebung von der Eingabeseite aus aussieht. Solche Punkte abzulehnen hat bei diesem ersten Foto vier von fünf Platzierungen gekostet. Ein Test der JS-Pipetten korrigierte außerdem eine meiner eigenen Behauptungen: Er behauptete, dass Tafelziele nichts clippen, und er scheiterte, weil bei warmem Licht der Eingangs-Weißpunkt des Grünkanals bei 0.949 lag. Glanzlichter dürfen heller sein als ein diffuses weißes Feld, deshalb sagt die Behauptung jetzt, was Tafelziele tatsächlich liefern: Das Feld ist nicht mehr die Decke. Die JS-Pipetten und dieser Python-Löser liegen in verschiedenen Repositorys, deshalb pinnt ein Test Fixtures, auf die sich beide einigen müssen.
Die LUT, die sechzehn Codes kostete
Das Ergebnis der Tafel wird als kleiner Datensatz inputLevels gespeichert und exakt, pro Kanal, am Eingang des Grades ausgewertet. Bis zum 23. September wurde es stattdessen in eine 65³-Eingangs-LUT eingebrannt. Levels sind separierbar, daher reduzieren sich trilineare und tetraedrische Abtastung beide auf lineare Interpolation zwischen Knoten im Abstand 1/64, und x^(1/γ) ist direkt neben dem Schwarzpunkt am steilsten. Das Audit maß dort 13.8 Codes Fehler, und die neue Prüfung fand 16.3 Codes auf ihren Samples mit Gamma 3. Die exakte Auswertung bringt es auf 1.1e-16.

Ich habe die Falle für diesen Artikel mit der Engine nachgestellt: Schwarz 0.10, Weiß 0.95, Gamma 3, in einen 65³-CUBE eingebrannt, wieder importiert und auf einer dichten Rampe verglichen. Bei einer Rampe mit 2.049 Punkten war der schlimmste Fehler 21.3 Codes knapp unterhalb des Schwarzpunkts, wo das exakte Ergebnis 0 ist und das Gitter immer noch zum nächsten Knoten interpoliert, und 10.7 knapp darüber; fein genug abgetastet liegt der Scheitel bei etwa 22.7 Codes auf beiden Seiten des Schwarzpunkts. Oberhalb der Eingabe 0.16 bleibt der Fehler bei etwa einem Viertel Code (höchstens 0.251). Eine separierbare Funktion pro Kanal gehört nicht in ein 3D-Gitter, wenn man sie exakt auswerten kann.
Automatische Levels und automatische Farbe: entworfen, nicht gebaut
Die Engine hat noch keinen Auto-Knopf. Der Entwurf ist aufgeschrieben: Histogramme aus der unveränderten Eingabe der gewählten Stufe und des gewählten Bereichs, sichtbare Clip-Anteile an beiden Enden und Schwarz- und Weißpunkte bei diesen Quantilen. Der Modus pro Kanal nimmt ein Paar pro Kanal und ändert die Farbe; der Kompositmodus nimmt ein einziges Paar und behält die neutrale Achse. Ein Bereich, der enger ist als eine Schwelle, bedeutet „undefiniert“, nicht „Identität“, und die gefundenen Parameter werden gespeichert, damit das erneute Öffnen eines Projekts sie nie still neu berechnet. Die Anteile müssen sichtbar sein, weil selbst die Referenz nicht feststeht: Adobes Seite zu schnellen Tonwertkorrekturen nennt in verschiedenen Abschnitten sowohl 0.5 % als auch 0.1 % als Clipping-Voreinstellungen.
Grenzen
- Levels läuft auf 8-Bit-SDR-kodiertem-sRGB im Hauptfenster. Keine Clipping-Vorschau auf dem Bild, nur schattierte Enden im Histogramm.
- Das Panel bearbeitet den Mastersatz. Pipetten pro Kanal existieren als geprüfte Löser, sind aber noch nicht damit verbunden.
- Tafelziele sind veröffentlichte Werte für eine Tafelgeneration, keine Spektralphotometer-Messung deiner Tafel.
- Keine automatischen Levels und keine automatische Farbe.
Nächster Teil: Kurven: Splinewahl, Überschwingen, Luma und LUT-Einbrennen. Meine On-Device-Retusche, ein C++-Photoshop-Plugin mit eigenen Modellen, steht auf happyin.ai.