露出とホワイトバランス:線形光における3つの乗算
著者: Anastasiia Butova, ComfyUI・拡散モデルエンジニア(ベオグラード) · 公開 2026-09-23
英語版からの翻訳: English
私が作っているカラースタジオでは、露出とホワイトバランスは1つの操作です。線形の RGB に対する3つの乗算です。面白いところは、その乗算の周りにあります。どの値を線形とみなすか、グレーの明るさを保つためにゲインを何で正規化するか、チャンネルが1を超えたらエンジンが何をするか。これは、基本的なカラーツールがどう実装されているかについてのシリーズの第1回です。数学、C++、そしてテストが見つけた間違いを扱います。
エンジンと、その動作空間
このスタジオは、LUT Atelier を基にしたローカルのエディタです。LUT Atelier は、もともと私の研究ハブで書いたカラーエディタで、その後すべてのソースファイルの SHA-256 でスタジオに固定しました。以下で説明するいくつかの修正は、固定の後にそのエンジンに入ったものです。ネイティブ側は C++20 で、標準ライブラリだけを使います。サードパーティのファイルは1つだけで、コマンドラインのプロトコル用に同梱した nlohmann/json 3.12.0 です。数学の JavaScript 版は、独立した検証基準として残しています。600個のランダムなグレードに対するシード付きの実行では、両エンジン間で 196,200 個の出力値を比較し、最大の差は 8.7e-14 です。
入力については正直に書いています。JPEG、WebP、8ビットの PNG は、ブラウザの Canvas を通って8ビットの sRGB として届きます。別のラボのウィンドウは、Canvas に丸められずに16ビットの PNG を読みます。RAW のデコーダはなく、HDR もなく、sRGB 以外の ICC プロファイルもありません。色変換は double で計算し、出力を書くときにだけ丸めます。
sRGB の値はエンコードされていて、線形ではありません。エンジンは、どの乗算よりも前に、IEC 61966-2-1 の曲線で各チャンネルを復号します。
c_lin = c / 12.92 if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4 otherwise
0.5 のミッドグレーは 0.214 に復号されます。この数字が、この記事のほとんどの理由です。
エンジン内部でのグレードの順序は、native/src/color.cpp から次のとおりです。
- 入力の段:チェッカーのレベル補正、またはインポートした LUT。1つの強度スライダーでブレンドされる。
- 線形への復号の後、ホワイトバランス、露出、色温度・色かぶりのゲインを掛ける。
- 任意の Oklab の編集(色相、彩度、色のピン、参照マッチ)、その後エンコード済みの RGB へ戻す。
- コントラスト、マスターのカーブ、R/G/B のカーブ。
- フォトステージ:レベル補正、ルマのカーブ、トーンのスライダー、ビブランス、グレーディングホイール、Selective Color。
- チャンネルミキサー、色の保護、グローバルな強度。
露出は乗算なので、線形光の中で行う
露出は、線形の RGB に対する 2^EV です。+1 EV で光は2倍になり、−1 EV で半分になります。スライダーは −3 から +3 までです。線形の値で行わなければならない理由は物理的です。光が2倍なら線形の信号も2倍です。エンコードされたコードの値が2倍になるわけではありません。

この図は、エンジンそのものからサンプリングしたものです。+1 EV では、入力コード128は175.6になり、入力コード188で白に達します。代わりにエンコード済みの値を2倍にすると、128以上がすべて白になり、ランプの上半分が失われます。Adobe は Photoshop の露出調整を、線形のカラースペースでの計算として文書化しています。Lightroom は +1.00 を約1段の絞りと説明しています。彼らの式を再現しているとは主張しません。この乗算は、単にその言葉が意味することです。
C++ でのパス全体は次のとおりです。露出と色温度・色かぶりは、グレードごとに一度だけ1つのゲインベクトルにまとめられ、ピクセルのループは3回の乗算だけです。
// 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]};
}
明記している不変条件は単純です。EV 0 は正確に1の乗数で、数学のスイートは +1 EV が線形の RGB を誤差ゼロで2倍にすることを確認しています。+1 のあとに −1 を掛けると、何もクリップされなかった場所では入力に戻ります。グレーのランプで測ったところ、往復の誤差は 4e-14 コードでした。
ホワイトバランス:同じ乗算を、チャンネルごとに
ここでのホワイトバランスは、線形の RGB の対角スケーリングです。フォン・クリース・モデルで、チャンネルごとに1つのゲインがあります。ゲインは2か所から来ます。中立ピッカーが wb を書き、色温度と色かぶりのスライダーが相対的な乗数を作ります。
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
Y(w) による除算は、最初に間違えた部分です。それがなければ、色かぶり +1 は緑を 1.25 倍にし、グレーは log2(1.179)、約 0.24 EV 明るくなります。色かぶり −1 では 0.28 EV 暗くなります。色を変えるはずのスライダーが、露出も変えていたのです。除算を入れると、色温度と色かぶりは、線形の輝度を変えずに中立の色を塗り替えます。ミッドグレーを色かぶり +1 に通すと、(118.1, 131.0, 118.1) になり、その線形の Y は入力と最後のビットまで一致します。
これらのスライダーを駆動するコンパスは、温度計ではなく、有界な2つの数値の図です。マーカーは x = cx + R·tanh(temperature)、y = cy − R·tanh(tint) の位置にあり、ドラッグは atanh で逆算します。その値は、スライダー範囲の端である tanh(1) のすぐ下の 0.7615 でクランプされます。ここに Kelvin はありません。Lightroom は RAW ファイルには Kelvin を表示しますが、それ以外のファイルには相対的な −100〜+100 の目盛りを表示します。Capture One は Kelvin と色かぶりを表示します。JPEG は、通常はカメラや RAW 現像ソフトで一度すでにホワイトバランスされています。カメラのプロファイルがなければ、Kelvin を変換する手段もありません。相対的な目盛りのほうが正直です。
中立ピッカーは、クリックした場所の明るさを保つ
グレーのパッチをクリックすると、エンジンはそれを線形の (r, g, b) に復号し、次を設定します。
g_c = Y / c, Y = 0.2126·r + 0.7152·g + 0.0722·b
乗算の後、3つのチャンネルはすべて Y に等しくなります。クリックした点は、それ自身の輝度のグレーになります。固定した版では、代わりに幾何平均 (r·g·b)^(1/3) で正規化していました。それはゲインの積を1に保つので、きれいに聞こえます。しかし、クリックした点は明るくなったり暗くなったりしました。テストのピック(0.6, 0.45, 0.3)では 0.30 EV、緑がかったピック(0.3, 0.6, 0.3)のようなものでは約 1.06 EV です。色のツールが、またしても露出を黙って動かしていたのです。輝度による版は9月22日に入り、チェックのセットでは JS と C++ のエンジンがビット単位で一致しています。
// 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;
}
ピックが成功すると、色温度と色かぶりも0に戻されるので、画面のゲインがバランスのすべてになります。8を超えるゲインが必要なピックは、そのメッセージで拒否され、ゼロのチャンネルを持つピックには独自のメッセージが出ます。クランプされたゲインより拒否のほうが好きです。クランプされたゲインは、ツールが働いたように見える結果を生むからです。スイートのテストケースは、強く色のついたサンプルで、ゲイン (4.40, 1.10, 0.24) が必要です。乗算の後、そのチャンネルは等しくなり、ばらつきはちょうど0です。
公式そのものより、クリックする対象のほうが重要です。1枚の写真では、光の色と物体の色を分けられません。線形の信号は I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ です。光 E に任意のスペクトル関数を掛け、反射率 R を同じ関数で割ると、同じピクセルになります。そのため、ピッカーは、グレーのカードや、中立だと分かっている面の上に置くべきです。肌や、染料が不明な「白」の T シャツの上には決して置きません。同じ理由で、エンジンにはまだ自動のホワイトバランスがありません。自動的な方法はどれも仮定に依存していて、古典的なグレーワールドの仮定は、実際に緑のシーンをグレーに変えてしまいます。研究の検査では、その反例をわざと残しています。
既知の中立に対して実装されているのは、チェックチャートのパネルです。1つの既知の中立パッチ、そのエンコード済みと線形の RGB、ゲイン、そしてゲインを掛けた後の残差を表示します。パッチそのものについては、残差は構成上ゼロです。そのため、役に立つ検査は、フレーム内の他の中立で、RGB のパレードの上で読むことです。複数のパッチのためのソルバー(パッチごとの比の中央値)は、書かれていますが作られていません。互いに一致しないパッチは、隠れた「理想の」バランスへ平均されず、その不一致はパレードの上に見えたままにします。
まだ変えていない順序
現在のエンジンでは、インポートした LUT がホワイトバランスと露出より前に動きます。ルックの LUT なら、それは逆です。まずフレームを正規化し、その後にルックを適用したいのです。そうしないと、同じルックが連続したフレームのすべてで違う結果になります。順序を黙って変えると、保存されたすべてのプロジェクトが変わってしまうので、まだ変えていません。バージョン付きのレシピが必要です。古いプロジェクトは古い順序のまま、新しいものは新しい順序を使います。それまで、中立ピッカーは入力の段の後のピクセルを読むので、そのゲインは少なくとも、乗算が実際に受け取るものを指しています。
1.0 を超えたら何が起きるか
エンジンは SDR です。露出とホワイトバランスだけの場合、各チャンネルは乗算の直後にエンコードされ、[0, 1] にクランプされます。Oklab の編集が有効なら、クランプはもっと後、遅くとも結果を書くときに行われます。そこから2つのことが言え、どちらも目に見えます。
第一に、クリップされた細部は戻りません。露出の後でカーブを下げても、平坦な255が暗くなるだけです。その上の値は、1段階前に捨てられています。露出ツールのドキュメントもそう書いています。
第二に、チャンネルごとのクリップは色相を変えます。赤が1に達し、緑と青が増え続けると、色は黄色の方向へずれます。

数字はエンジンからのものです。暖色の sRGB(204, 133, 87)の Oklab の色相は 52.9° です。その赤チャンネルは +0.73 EV で 255 に達します。+1 EV では出力は (255, 182, 121) になり、色相は 60.4° です。+2 EV では (255, 248, 166)、色相 103.9° です。オレンジが淡い黄色になってしまいました。
オプトインの代替手段として、ソフトな色域圧縮があります。各チャンネルを切るのではなく、範囲外の色を、線形 sRGB の直線の上で、同じ Oklab の明度のグレーに向かって動かし、立方体に収まるまで続けます。範囲の内側から始まった色に対して Oklab の創作的な編集を行った後は、少なくとも、グレーから境界までの距離の最初の 80 % は正確なまま保たれ、残りだけが指数の肩を持つ曲線で圧縮されます。露出だけの場合、基準点は露出された色そのものであり、それはすでに範囲の外にあるので、そこでは単純に表面への射影になります。同じ暖色の色で、+1 EV は (255, 192, 159)、色相 48.5° を与えます。色相の移動は小さくなりますが、彩度は、ハードクリップの結果(0.115、元の色は 0.107)から 0.085 に下がります。さらに、Oklab の明度が 1 に達する約 +1.67 EV からは、結果は純粋な白になります。どちらのオプションもハイライトの復元ではありません。
最初の版は教科書どおりのものでした。Oklab の明度と色相を固定し、その光線に沿って彩度を下げるものです。これは青の近くで失敗します。青の側では、明度と色相を固定した光線が色域から出て、また戻ることがあり、連続でなければならない写像は、その隙間を飛び越えられません。264° の近くの小さな再侵入は、私自身の測定です。エンジンの行列を使ったもので、彼のものではありません。Björn Ottosson の Oklab の記事 は、初期のフィットが青を内側に折り畳んでしまい、最終的な Oklab を、それが起きないように制約したと書いています。ソフトなオプションの Oklab の枠組みと、ハードな RGB クリップへの警告は、彼の 色域クリッピングのノート から来ています。同じ明度のグレーへ向かう線形 RGB の線分は、RGB の立方体が凸なので、交差は1回だけです。そのため、連続性は自然に得られます。ただし、私が測定したコストがあります。明るい暖色のランプで +0.8 EV のとき、隣接する8ビットの入力コードが、出力で19コード離れることがあります。それはジャンプではなく急な応答です。テストは最大20を許容し、21で失敗します。
Capture One は、クリップしていないチャンネルを使えるハイライトの復元を文書化しています。それは、余裕のある生の raw データに対して動きます。8ビットの sRGB 入力では、復元すべき255より上のものが何もありません。そのため、細部をでっち上げるより、クリップを正直に見せるほうを選びます。
限界と、次にやること
- メインウィンドウは8ビットの SDR sRGB。16ビットの PNG はラボのウィンドウだけ。RAW なし、Kelvin なし、ICC なし。
- 既知の1つのパッチからのホワイトバランス。複数パッチと自動推定は設計されていて、作られていません。
- インポートした LUT は、まだホワイトバランスより前に動きます。修正にはバージョン付きのレシピが必要です。
次に同じことをするなら、初日から、色温度と色かぶりをそれぞれの輝度で割り、数学の実装を検証用に2つ持ち、ピッカーにはゲインをクランプさせず、理由を添えて拒否させます。
次の部分は、チャートによるレベル補正、ヒストグラム、黒と白の点です。そこでは、同じエンジンを、わざとエンコードされた値の上で動かします。私が Photoshop 向けに出荷しているオンデバイスの C++ レタッチプラグインは、happyin.ai で説明しています。