レベル補正:黒点と白点、ガンマ、チャンネル、チャート
著者: Anastasiia Butova, ComfyUI・拡散モデルエンジニア(ベオグラード) · 公開 2026-09-23
英語版からの翻訳: English
レベル補正は、最も謎の少ないトーンのツールです。クランプと直線と累乗です。私のカラースタジオでは、わざとエンコード済みの sRGB の値に対して動きます。これは、第1部の線形光での乗算の後です。式は3行です。この記事は、その周りのすべてを扱います。マスターとチャンネルの設定の順序、ヒストグラムが実際に何を測るのか、スポイトのソルバー、チャートから取る黒点と白点、そして黒のすぐ近くで16コードを失わせた1つの LUT についてです。
式
0〜1の1つのチャンネル値 x に対して、入力の黒 b、入力の白 w、ガンマ γ、出力の黒と白 ob、ow を使うと、次のようになります。
u = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)
b は ob に、w は ow に写されます。入力範囲の外にあるものはすべてクリップされます。b = 0、w = 1、γ = 1、ob = 0、ow = 1 のとき、この関数は恒等写像です。パネルは入力と出力の点を0〜255として、ガンマを0.2〜5として表示します。エディタは入力の黒と白の間を最低 1/255 あけますし、モデル自体も w − b < 1e-6 を拒否します。出力のペアにはそのような規則はありません。出力の黒が出力の白より大きいのは反転で、反転は正当な要求です。
グレーのハンドルは、カーブが出力範囲の中間を出力する位置にあります。
m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5
そのため、1より大きいγはハンドルを黒の方へ動かし、中間調を明るくします。1より小さいγはその逆をします。

Adobe は Photoshop のレベル補正で同じコントロールを文書化しています。Capture One はさらに進んでいて、その Levels のページには、入力の 0〜220 を出力の 0〜244 に写す線形の例があり、中央のスライダーをガンマと説明しています。どちらも実装は公開していないので、どちらとのビット単位の一致も主張しません。私が主張するのは、上の関数がスタジオの両エンジンが実行する関数だということです。
なぜ線形光ではなくエンコード済みの値なのでしょうか。露出とホワイトバランスは光をモデル化するので、線形の RGB に掛けます。レベル補正は、人がコードの値のヒストグラムに対して動かす階調の写像です。線形光でのガンマのハンドルは、誰も予想しない場所に来てしまいます。だからこそ、レベル補正のグレーのスポイトと、ホワイトバランスの中立ピッカーは別の操作なのです。一方はエンコード済みの値で各チャンネルのガンマを曲げ、もう一方は線形のチャンネルを掛けます。
マスターが先、その後に各チャンネル
グレードには、1つのマスターの設定と、3つのチャンネルの設定があります。マスターの設定は、チャンネルの設定ができる前から使っていたのと同じフラットなフィールドに保存されるので、チャンネルの設定ができる前に保存されたプロジェクトは、バイト単位で同一のままです。各チャンネルはまずマスターを通り、その後に自分の設定を通ります。
// 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)) は、このエンジンのための私の判断であり、Photoshop の内部の順序についての文書化された事実ではありません。
2種類の設定の違いが、両方が存在する理由です。3つのチャンネルすべてに同じ関数をかけると、グレーはグレーのままですが、色の R:G:B の比は保たれないので、彩度がコントラストと一緒に動きます。チャンネルごとの設定はチャンネルを互いに離すので、まさに色かぶりを取り除き、誤用すれば色かぶりを加えます。
このスニペットのコメントは、数字からしか理解できなかった罠を隠しています。フォトステージの中では、レベル補正は Oklab の処理より前に動きますが、その前にあるグローバルなカラー変換は、すべてのピクセルを Oklab に通して戻していました。その往復は、正確な算術の中でしか恒等写像ではありません。そして、レベル補正のガンマは、ゼロの近くの誤差を増幅しました。γ = 5 のとき、本来0であるべきチャンネルが 1e-6 として戻ってくると、レベル補正の後は 0.063 になります。8ビットのコードで16個分です。エンジンの最初の Oklab の逆変換は、公開された10桁の行列を使っていて、恒等写像と一致するのは 1.18e-6 の範囲内だけでした。今では厳密な逆変換になっています(1.9e-14)。グローバルな制御が Oklab を必要としないグレードは、その段をまるごとスキップします。スイートの γ = 5 のケースは、JS と C++ の間に差がないことを示します。
2つ目の罠はもっと静かでした。しばらくの間、JavaScript エンジンはチャンネルごとのレベル補正を適用していましたが、プレビューと、すべての書き出しを描く C++ エンジンは、それを黙って捨てていました。独立した監査では、最大0.1875の差が見つかりました。ピクセル(158, 128, 97)でのグレーのスポイトの修正は、JS では(127.5, 127.5, 127.5)、C++ では変化なしになりました。今では両方がその設定を適用します。ネイティブのパーサーは、知らないチャンネル名を読み飛ばすのではなく拒否します。257個のサンプルでのパリティ検査は 1.1e-16 を示します。
ヒストグラムが測っているもの
レベル補正のコントロールの上にあるヒストグラムは、256個のビンを持ちます。R、G、B は同じビンに別々に数えられ、各ピクセルはアルファで重み付けされ、完全に透明なピクセルは飛ばされます。ストライドで最大32,768ピクセルをサンプリングし、ピークが他のすべてを平坦にしないように頻度の平方根を描き、黒点より下と白点より上の範囲を陰影で示します。
重要なのは、どこで測るかです。レベル補正の実際の入力を、レンダリングのパイプラインそのものから取ります。最初の版は、ヒストグラムとカーブの読み出しのために、グレードの切り詰めたコピーを再実行していました。そのコピーは後段のいくつかを残し、レベル補正の一部を落としていました。監査では、「レベル補正の前」のヒストグラムに純粋な黄色(255, 255, 0)が(229, 217, 102)として描かれているのが見つかりました。また、チャンネルミキサーをオンにした状態での赤の読み出しは、実際の入力と一致せず、139.2 対 158.1 でした。今では、1つのコンパイル済みのパイプラインが自分の中間入力を報告し、両方の読み出しがそれを使います。回帰スイートの21ケースでは、誤差は 0 です。
スポイト:拒否する3つのソルバー
3つのスポイトは、クリックした1つのピクセルから、チャンネルごとのパラメータを解きます。チャンネルごとであることが要点です。すべてのチャンネルに同じ調整をかけても、等しくないチャンネルを等しくはできません。したがって色かぶりは取り除けません。目標を t とすると、そのために入力範囲の中で必要な位置は u = ((t − ob)/(ow − ob))^γ で、
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)
グレーの解では、u_c と v_c の両方が (0, 1) の内側に厳密にある必要があります。端点では、ガンマは存在しないので、クリップされた、あるいは潰れたピクセルは、理由とともに拒否されます。ガンマ 1 に置き換えることはしません。それではツールが何もしていないように見えてしまうからです。ガンマが 0.2〜5 の外にある場合も拒否されます。既定のグレーの目標はサンプル自身の平均で、そのためピクセルはおおよその明るさを保ち、色かぶりだけを失います。
これらのソルバーは監査で 6.7e-16 の精度でした。率直な状態は、levels-picker.mjs に入っていて、まだパネルにはつながっていないということです。パネルはマスターの設定を編集し、チャンネルごとの設定は、今のところ保存されたプロジェクトからしかエンジンに届きません。下のチャートの経路には、グレードの入力にチャンネルごとのレベル補正の記録が別にあります。
チャートから取る黒点と白点
スポイトの最初の版は、クリックしたピクセルを純粋な黒と純粋な白に送っていました。これは Photoshop の既定と一致しますが、ColorChecker に対しては間違っています。Classic24 の公開された sRGB の値は、X-Rite に帰属していて、白のパッチを243、黒のパッチを52に置きます。白のパッチを255に送ると、シーンが243から255の間に持っていたものをすべて捨ててしまいます。黒のパッチを0に送ると、52レベルの影を潰します。X-Rite 自身の露出のガイドも、平易な言葉で同じことを言っています。白い正方形は非常に明るいが純粋な白ではなく、黒のパッチは純粋な黒に見えるべきではない、というものです。白のパッチは約90%の拡散反射体なので、フレーム内の鏡面ハイライトは、それより明るくなるべきものです。
Photoshop は、そのスポイトの目標の色を編集できます。それは、0と255が慣習であり、ツールの性質ではないことを示しています。そのため、ソルバーは目標を受け取り、公開された値を持つチャートのプリセットがあります(2014年11月以降に作られたチャート用です。それ以前のチャートでは数値が違います)。
黒をクリックしてから白をクリックするやり方には、2つ目の問題があります。2回目のクリックで入力の白が動き、入力範囲のすべての位置がそれとともに動くので、黒のパッチが目標から外れてしまいます。自由な手作業のピックは、これを避けられません。なぜなら、各クリックは自分の基準しか知らないからです。チャートがあれば、両方の基準が同時に分かるので、そのペアは一緒に解かれます。
w − b = (s_w − s_b) / (u_w − u_b), b = s_b − u_b · (w − b)
エンドツーエンドで配線されている経路は、私のカラーチェッカーのプロジェクトにある、別の UNet/VAE モデルから始まります。そのモデルは、フレーム内で選んだ場所で Classic24 がどう見えるかを予測します。そのため、検出された物理的なチャートではなく、24個の仮想パッチが得られます。Python のソルバーが、予測されたチャートをチャンネルごとのレベル補正に変換します。
# 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
ガンマは、両端の間にある4つのグレーから得られます。各グレーは、0.2〜5の範囲内で60ステップの二分法により、チャンネルごとに独自のガンマを要求し、その中央値の答えが採用されます。最小二乗の平均では、1つの悪いパッチがすべてを引っ張ってしまいます。3つのグレーがガンマ 1 を要求し、4つ目が 0.02 に潰れている場合、フィットはガンマ 2.74 を要求します。1つのグレーだけに頼るのは、別の意味で脆いものです。最初の実写真では、パッチ 20 だけが、5回の配置のうち3回で届かなかったのに、その周りのランプは普通でした。
ソルバーは0〜1の外の入力点を許容します。0より下の黒点は、入力の側から見たリフト(持ち上げ)の形だからです。そのような点を拒否すると、その最初の写真では5回の配置のうち4回を失いました。JS のスポイトのテストは、私自身の主張も1つ訂正しました。そのテストは、チャートの目標が何もクリップしないと主張していましたが、それは失敗しました。暖色の光では、緑チャンネルの入力の白が 0.949 に着地したからです。拡散白のパッチより明るいハイライトは許されるので、アサーションは、チャートの目標が実際に何を与えるかを言うように変わりました。パッチは天井ではなくなります。JS のスポイトとこの Python のソルバーは別々のリポジトリにあるので、両方が一致しなければならない固定データをテストが固定しています。
1つの LUT が、黒の近くで16コードを失わせた
チャートの結果は、小さな inputLevels の記録として保存され、グレードの入力でチャンネルごとに正確に評価されます。9月23日までは、代わりに 65³ の入力 LUT に焼き込まれていました。レベル補正は分離可能なので、トリリニア補間もテトラヘドラル補間も、1/64 離れたノード間の線形補間に帰着し、x^(1/γ) は黒点のすぐ隣で最も急になります。監査では、そこで 13.8 コードの誤差が測定され、新しい検査では、ガンマ 3 のサンプルで 16.3 コードが見つかりました。厳密な評価にすると、誤差は 1.1e-16 になります。

この記事のために、エンジンを使ってこの罠を再現しました。黒0.10、白0.95、ガンマ 3 で、65³ の CUBE に焼き込み、再び読み込み、密なランプで比較します。2,049点のランプでは、最悪の誤差は黒点のすぐ下で 21.3 コードでした。そこでは正確な結果は 0 ですが、格子は次のノードに向けて補間してしまいます。黒点のすぐ上では 10.7 でした。十分細かくサンプリングすると、ピークは黒点の両側でおよそ 22.7 コードです。入力 0.16 より上では、誤差はおよそ4分の1コード(最大 0.251)のままです。分離可能なチャンネル別の関数は、厳密に評価できるなら、3D の格子に入れるべきではありません。
自動レベルと自動カラー:設計済み、未実装
エンジンにはまだ自動ボタンがありません。設計は文書化されています。選ばれた段と領域の、変更されていない入力からのヒストグラム、両端での見えるクリップの割合、その割合の分位点での黒点と白点です。チャンネルごとのモードは、チャンネルごとにペアを取り、色を変えます。コンポジットのモードは1つのペアを取り、中立軸を保ちます。しきい値より狭い範囲は「未定義」を意味し、「恒等」を意味しません。見つかったパラメータは保存されるので、プロジェクトを開き直したときに黙って再計算されることはありません。割合を見えるようにする必要があるのは、基準そのものがまだ決まっていないからです。Adobe のクイックなトーン調整のページでは、異なるセクションで、クリップの既定値として0.5%と0.1%の両方が挙げられています。
限界
- レベル補正は、メインウィンドウでは8ビットの SDR のエンコード済み sRGB で動きます。画像上のクリップのプレビューはなく、ヒストグラムの端に陰影があるだけです。
- パネルはマスターの設定を編集します。チャンネルごとのスポイトは、テスト済みのソルバーとして存在しますが、まだそれにはつながっていません。
- チャートの目標は、1世代のチャートに対する公開された値であり、お使いのチャートの分光光度計による測定値ではありません。
- 自動レベルや自動カラーはありません。
次の部分は、カーブ:スプラインの選択、オーバーシュート、ルマ、LUT 焼き込みです。私のオンデバイスのレタッチ作業、独自のモデルを持つ C++ の Photoshop プラグインは、happyin.ai にあります。